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.6.0 - โญ Add thumbnails to Discord and Bluesky posts for new levels - ๐Ÿ› ๏ธ Fixed issue where levels with no logging data couldn't be deleted - ๐Ÿ“œ Create OpenAPI schema - ๐Ÿ“œ Create `CHANGELOG.md`

Clay e4809339 bba5c01e

+1186 -38
+51
CHANGELOG.md
··· 1 + # Changelog 2 + 3 + ## __0.6.0__ 4 + 5 + - โญ Add thumbnails to Discord and Bluesky posts for new levels 6 + - ๐Ÿ› ๏ธ Fixed issue where levels with no logging data couldn't be deleted 7 + - ๐Ÿ“œ Create OpenAPI schema 8 + - ๐Ÿ“œ Create `CHANGELOG.md` 9 + 10 + ## 0.5.3 11 + 12 + - โญ Add PostHog analytics 13 + - โญ Add OpenTelemetry logging (for use with PostHog logs) 14 + 15 + ## 0.5.2 16 + 17 + - ๐Ÿ› ๏ธ Improve database structure for logging 18 + - โญ Add feature logging to Bluesky logging provider 19 + 20 + ## 0.5.1 21 + 22 + - โญ Add auto-publishing to Discord messages posted in announcement channels 23 + 24 + ## __0.5.0__ 25 + 26 + - ๐Ÿ› ๏ธ Refactored logging system to be modular 27 + - โญ Added Bluesky logging provider 28 + 29 + ## 0.4.2 30 + 31 + - ๐Ÿ› ๏ธ Fix zombie decoding 32 + 33 + ## 0.4.1 34 + 35 + - โญ Add current version to `/api/health` 36 + 37 + ## __0.4.0__ 38 + 39 + - โญ Support for new zombie picker 40 + - โญ Add profanity filter (via `bad-words`) 41 + 42 + ## __0.3.0__ 43 + 44 + - โญ Add new Featured sort 45 + 46 + ## __0.2.0__ 47 + 48 + - ๐Ÿ› ๏ธ Fix a bug where when an admin changes some level metadata (e.g. sun) the level data doesn't reflect the change 49 + - ๐Ÿ› ๏ธ Fix erroneous login prompt on admin dashboard when authentication is disabled 50 + - ๐Ÿ› ๏ธ Fix incorrect plants map 51 + - ๐Ÿ› ๏ธ Refactor level encoder/decoder
+1 -1
README.md
··· 1 - # PVZM Backend ![v0.5.3](https://img.shields.io/badge/version-v0.5.3-darklime) 1 + # PVZM Backend ![v0.6.0](https://img.shields.io/badge/version-v0.6.0-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
+6 -3
deno.json
··· 1 1 { 2 - "version": "0.5.3", 2 + "version": "0.6.0", 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", ··· 12 12 "net": true, 13 13 "ffi": true, 14 14 "read": true, 15 - "write": true 15 + "write": true, 16 + "import": true 16 17 }, 17 18 "dev": { 18 19 "env": true, 19 20 "net": true, 20 21 "ffi": true, 21 22 "read": true, 22 - "write": true 23 + "write": true, 24 + "import": true 23 25 } 24 26 }, 25 27 "lint": { ··· 30 32 "imports": { 31 33 "@atproto/api": "npm:@atproto/api@^0.18.20", 32 34 "@db/sqlite": "jsr:@db/sqlite@^0.13.0", 35 + "@gfx/canvas": "jsr:@gfx/canvas@^0.5.8", 33 36 "@mathis/turnstile-verify": "jsr:@mathis/turnstile-verify@^1.2.0", 34 37 "@msgpack/msgpack": "npm:@msgpack/msgpack@^3.1.3", 35 38 "@openai/openai": "jsr:@openai/openai@^6.17.0",
+79 -3
deno.lock
··· 3 3 "specifiers": { 4 4 "jsr:@db/sqlite@0.13": "0.13.0", 5 5 "jsr:@denosaurs/plug@1": "1.1.0", 6 + "jsr:@denosaurs/plug@1.0.5": "1.0.5", 7 + "jsr:@gfx/canvas@~0.5.8": "0.5.8", 6 8 "jsr:@mathis/turnstile-verify@^1.2.0": "1.2.0", 7 9 "jsr:@openai/openai@^6.17.0": "6.17.0", 10 + "jsr:@std/assert@0.214": "0.214.0", 11 + "jsr:@std/assert@0.217": "0.217.0", 12 + "jsr:@std/encoding@0.214": "0.214.0", 13 + "jsr:@std/encoding@0.217.0": "0.217.0", 8 14 "jsr:@std/encoding@1": "1.0.10", 15 + "jsr:@std/fmt@0.214": "0.214.0", 9 16 "jsr:@std/fmt@1": "1.0.9", 17 + "jsr:@std/fs@0.214": "0.214.0", 18 + "jsr:@std/fs@0.217.0": "0.217.0", 10 19 "jsr:@std/fs@1": "1.0.22", 11 20 "jsr:@std/fs@^1.0.22": "1.0.22", 12 21 "jsr:@std/internal@^1.0.12": "1.0.12", 22 + "jsr:@std/path@0.214": "0.214.0", 23 + "jsr:@std/path@0.217": "0.217.0", 24 + "jsr:@std/path@0.217.0": "0.217.0", 13 25 "jsr:@std/path@1": "1.1.4", 14 26 "jsr:@std/path@1.0": "1.0.9", 15 27 "jsr:@std/path@^1.1.4": "1.1.4", ··· 36 48 "@db/sqlite@0.13.0": { 37 49 "integrity": "4545c635e0b3d4ddfdc0f2240f932f24b8ad0178e9c2e3a0f9403e7b18ae2fb5", 38 50 "dependencies": [ 39 - "jsr:@denosaurs/plug", 51 + "jsr:@denosaurs/plug@1", 40 52 "jsr:@std/path@1.0" 41 53 ] 42 54 }, 55 + "@denosaurs/plug@1.0.5": { 56 + "integrity": "04cd988da558adc226202d88c3a434d5fcc08146eaf4baf0cea0c2284b16d2bf", 57 + "dependencies": [ 58 + "jsr:@std/encoding@0.214", 59 + "jsr:@std/fmt@0.214", 60 + "jsr:@std/fs@0.214", 61 + "jsr:@std/path@0.214" 62 + ] 63 + }, 43 64 "@denosaurs/plug@1.1.0": { 44 65 "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", 45 66 "dependencies": [ 46 - "jsr:@std/encoding", 47 - "jsr:@std/fmt", 67 + "jsr:@std/encoding@1", 68 + "jsr:@std/fmt@1", 48 69 "jsr:@std/fs@1", 49 70 "jsr:@std/path@1" 50 71 ] 51 72 }, 73 + "@gfx/canvas@0.5.8": { 74 + "integrity": "a61c80292528e7433d428556b494a0ea496dd8e6abd4a338b8b25fc04e46ea3e", 75 + "dependencies": [ 76 + "jsr:@denosaurs/plug@1", 77 + "jsr:@denosaurs/plug@1.0.5", 78 + "jsr:@std/encoding@0.217.0", 79 + "jsr:@std/fs@0.217.0", 80 + "jsr:@std/path@0.217.0" 81 + ] 82 + }, 52 83 "@mathis/turnstile-verify@1.2.0": { 53 84 "integrity": "52fb351400780627660e0fbb4c1ff1000fcd0b28d7eebf13753ac9548fa77030" 54 85 }, ··· 58 89 "npm:zod" 59 90 ] 60 91 }, 92 + "@std/assert@0.214.0": { 93 + "integrity": "55d398de76a9828fd3b1aa653f4dba3eee4c6985d90c514865d2be9bd082b140" 94 + }, 95 + "@std/assert@0.217.0": { 96 + "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" 97 + }, 98 + "@std/encoding@0.214.0": { 99 + "integrity": "30a8713e1db22986c7e780555ffd2fefd1d4f9374d734bb41f5970f6c3352af5" 100 + }, 101 + "@std/encoding@0.217.0": { 102 + "integrity": "b03e8ff94c98d6b6a02c02c5cf8e5d203400155516248964fc4559abc04669dc" 103 + }, 61 104 "@std/encoding@1.0.10": { 62 105 "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 63 106 }, 107 + "@std/fmt@0.214.0": { 108 + "integrity": "40382cff88a0783b347b4d69b94cf931ab8e549a733916718cb866c08efac4d4" 109 + }, 64 110 "@std/fmt@1.0.9": { 65 111 "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" 66 112 }, 113 + "@std/fs@0.214.0": { 114 + "integrity": "bc880fea0be120cb1550b1ed7faf92fe071003d83f2456a1e129b39193d85bea", 115 + "dependencies": [ 116 + "jsr:@std/assert@0.214", 117 + "jsr:@std/path@0.214" 118 + ] 119 + }, 120 + "@std/fs@0.217.0": { 121 + "integrity": "0bfff5f3618d68c385b28b4ffbf3a15c98293a0f1186444458b62e0111ce77b2", 122 + "dependencies": [ 123 + "jsr:@std/assert@0.217", 124 + "jsr:@std/path@0.217" 125 + ] 126 + }, 67 127 "@std/fs@1.0.22": { 68 128 "integrity": "de0f277a58a867147a8a01bc1b181d0dfa80bfddba8c9cf2bacd6747bcec9308", 69 129 "dependencies": [ ··· 73 133 }, 74 134 "@std/internal@1.0.12": { 75 135 "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 136 + }, 137 + "@std/path@0.214.0": { 138 + "integrity": "d5577c0b8d66f7e8e3586d864ebdf178bb326145a3611da5a51c961740300285", 139 + "dependencies": [ 140 + "jsr:@std/assert@0.214" 141 + ] 142 + }, 143 + "@std/path@0.217.0": { 144 + "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", 145 + "dependencies": [ 146 + "jsr:@std/assert@0.217" 147 + ] 76 148 }, 77 149 "@std/path@1.0.9": { 78 150 "integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e" ··· 922 994 "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 923 995 } 924 996 }, 997 + "remote": { 998 + "https://pvzm.net/game/js/CPlants.js": "84681cb3ce92059308d4cf11f6c9d9d21cfbc198f2498570f88c0a06be61273a" 999 + }, 925 1000 "workspace": { 926 1001 "dependencies": [ 927 1002 "jsr:@db/sqlite@0.13", 1003 + "jsr:@gfx/canvas@~0.5.8", 928 1004 "jsr:@mathis/turnstile-verify@^1.2.0", 929 1005 "jsr:@openai/openai@^6.17.0", 930 1006 "jsr:@std/fs@^1.0.22",
+20 -2
modules/logging/bluesky.ts
··· 47 47 } 48 48 } 49 49 50 + private async uploadImageEmbed(png: Uint8Array, alt: string) { 51 + if (!this.agent) return undefined; 52 + const uploadResponse = await this.agent.uploadBlob(png, { encoding: "image/png" }); 53 + return { 54 + $type: "app.bsky.embed.images" as const, 55 + images: [ 56 + { 57 + alt, 58 + image: uploadResponse.data.blob, 59 + aspectRatio: { width: 900, height: 600 }, 60 + }, 61 + ], 62 + }; 63 + } 64 + 50 65 async sendLevelMessage(level: LevelInfo): Promise<string | null> { 51 66 if (!this.agent) return null; 52 67 ··· 56 71 57 72 const rt = new RichText({ text: message }); 58 73 await rt.detectFacets(this.agent); 74 + 75 + const embed = level.thumbnail ? await this.uploadImageEmbed(level.thumbnail, level.name) : undefined; 59 76 60 77 const response = await this.agent.post({ 61 78 text: rt.text, 62 79 facets: rt.facets, 80 + embed, 63 81 }); 64 82 65 83 // return the post uri as the message id ··· 82 100 await this.agent.deletePost(messageId); 83 101 return true; 84 102 } catch (error) { 85 - console.error("Bluesky logging provider: deleteLevelMessage failed:", error); 103 + console.warn(`Bluesky logging provider: delete failed for post ${messageId}:`, (error as Error).message); 86 104 return false; 87 105 } 88 106 } ··· 121 139 await this.agent.deletePost(messageId); 122 140 return true; 123 141 } catch (error) { 124 - console.error("Bluesky logging provider: deleteFeaturedMessage failed:", error); 142 + console.warn(`Bluesky logging provider: featured delete failed for post ${messageId}:`, (error as Error).message); 125 143 return false; 126 144 } 127 145 }
+26 -6
modules/logging/discord.ts
··· 152 152 if (!this.channel) return null; 153 153 154 154 try { 155 + const embed = this.buildEmbed(level); 156 + const files: AttachmentBuilder[] = []; 157 + 158 + if (level.thumbnail) { 159 + const attachment = new AttachmentBuilder(Buffer.from(level.thumbnail), { name: "thumbnail.png" }); 160 + files.push(attachment); 161 + embed.setImage("attachment://thumbnail.png"); 162 + } 163 + 155 164 const message = await this.channel.send({ 156 165 content: "", 157 - embeds: [this.buildEmbed(level)], 166 + embeds: [embed], 158 167 components: [this.buildPublicUploadButtons(level)], 168 + files, 159 169 }); 160 170 await this.tryPublish(message); 161 171 return message.id; ··· 169 179 if (!this.adminChannel) return null; 170 180 171 181 try { 182 + const embed = this.buildEmbed(level); 183 + const files: AttachmentBuilder[] = []; 184 + 185 + if (level.thumbnail) { 186 + const attachment = new AttachmentBuilder(Buffer.from(level.thumbnail), { name: "thumbnail.png" }); 187 + files.push(attachment); 188 + embed.setImage("attachment://thumbnail.png"); 189 + } 190 + 172 191 const message = await this.adminChannel.send({ 173 192 content: "", 174 - embeds: [this.buildEmbed(level)], 193 + embeds: [embed], 175 194 components: [this.buildAdminUploadButtons(level)], 195 + files, 176 196 }); 177 197 await this.tryPublish(message); 178 198 ··· 203 223 }); 204 224 return true; 205 225 } catch (error) { 206 - console.error("Discord logging provider: edit failed:", error); 226 + console.warn(`Discord logging provider: edit failed for message ${messageId}:`, (error as Error).message); 207 227 return false; 208 228 } 209 229 } ··· 220 240 }); 221 241 return true; 222 242 } catch (error) { 223 - console.error("Discord logging provider: edit failed:", error); 243 + console.warn(`Discord logging provider: admin edit failed for message ${messageId}:`, (error as Error).message); 224 244 return false; 225 245 } 226 246 } ··· 233 253 await message.delete(); 234 254 return true; 235 255 } catch (error) { 236 - console.error("Discord logging provider: delete failed:", error); 256 + console.warn(`Discord logging provider: delete failed for message ${messageId}:`, (error as Error).message); 237 257 return false; 238 258 } 239 259 } ··· 258 278 }); 259 279 return true; 260 280 } catch (error) { 261 - console.error("Discord logging provider: delete failed:", error); 281 + console.warn(`Discord logging provider: admin delete failed for message ${messageId}:`, (error as Error).message); 262 282 return false; 263 283 } 264 284 }
+1
modules/logging/types.ts
··· 4 4 author: string; 5 5 gameUrl: string; 6 6 backendUrl: string; 7 + thumbnail?: Uint8Array; 7 8 }; 8 9 9 10 export type AdminLevelInfo = LevelInfo & {
+80
modules/plantImages.ts
··· 1 + import type { ServerConfig } from "./config.ts"; 2 + 3 + // deno-lint-ignore no-explicit-any 4 + const g = globalThis as any; 5 + 6 + export type PlantData = { 7 + PicArr: string[]; 8 + width: number; 9 + height: number; 10 + shadowStyle: string; 11 + }; 12 + 13 + export async function getPlantImages(config: ServerConfig): Promise<{ [key: string]: PlantData }> { 14 + const gameUrl = config.gameUrl.endsWith("/") 15 + ? config.gameUrl.slice(0, -1) 16 + : config.gameUrl; 17 + 18 + // snapshot existing keys so we can clean up after import 19 + const keysBefore = new Set(Object.keys(g)); 20 + 21 + // set up dummy globals so plant files can be imported 22 + const defaultGetShadow = (a: { width: number; height: number }) => 23 + "left:" + (a.width * 0.5 - 48) + "px;top:" + (a.height - 22) + "px"; 24 + g.InheritO = (_base: unknown, data: Record<string, unknown>) => { 25 + return { 26 + prototype: { 27 + PicArr: data?.PicArr ?? [], 28 + width: data?.width ?? 0, 29 + height: data?.height ?? 0, 30 + getShadow: data?.getShadow ?? defaultGetShadow, 31 + }, 32 + }; 33 + }; 34 + g.CPlants = { prototype: { PicArr: [], getShadow: defaultGetShadow } }; 35 + g.$User = { 36 + Visitor: { 37 + TimeStep: 0 38 + }, 39 + Browser: { 40 + IE6: false, 41 + }, 42 + Client: { 43 + Mobile: false, 44 + } 45 + }; 46 + g.$Random = ""; 47 + g.window = g; 48 + 49 + try { 50 + // dynamic import resolves all plant file imports via the game URL 51 + await import(`${gameUrl}/game/js/CPlants.js`); 52 + 53 + // CPlants.js sets window[plantName] for each plant. 54 + // In Deno, window === globalThis, so they're on g now. 55 + const plantImages: { [key: string]: PlantData } = {}; 56 + for (const key of Object.keys(g)) { 57 + const val = g[key]; 58 + if (val?.prototype?.PicArr && Array.isArray(val.prototype.PicArr)) { 59 + const w = val.prototype.width ?? 0; 60 + const h = val.prototype.height ?? 0; 61 + const getShadow = val.prototype.getShadow ?? defaultGetShadow; 62 + plantImages[key] = { 63 + PicArr: val.prototype.PicArr, 64 + width: w, 65 + height: h, 66 + shadowStyle: getShadow({ width: w, height: h }), 67 + }; 68 + } 69 + } 70 + 71 + return plantImages; 72 + } finally { 73 + // remove all keys added during import 74 + for (const key of Object.keys(g)) { 75 + if (!keysBefore.has(key)) { 76 + delete g[key]; 77 + } 78 + } 79 + } 80 + }
+73
modules/renderThumbnail.ts
··· 1 + import type { ServerConfig } from "./config.ts"; 2 + import { createCanvas, Image } from "@gfx/canvas"; 3 + import { izombiePlantsMap } from "./levels_io.ts"; 4 + import type { PlantData } from "./plantImages.ts"; 5 + 6 + const PUMPKIN_HEAD_INDEX = izombiePlantsMap.indexOf("oPumpkinHead"); 7 + 8 + async function loadImage(url: string): Promise<Image> { 9 + const res = await fetch(url); 10 + const buf = await res.arrayBuffer(); 11 + const img = new Image(); 12 + img.src = new Uint8Array(buf); 13 + return img; 14 + } 15 + 16 + export async function renderThumbnailCanvas( 17 + thumb: number[][], 18 + isWater: boolean, 19 + plantImages: { [key: string]: PlantData }, 20 + config: ServerConfig, 21 + ): Promise<Uint8Array> { 22 + const gameUrl = config.gameUrl.endsWith("/") 23 + ? config.gameUrl.slice(0, -1) 24 + : config.gameUrl; 25 + const baseUrl = `${gameUrl}/game/`; 26 + 27 + const canvas = createCanvas(900, 600); 28 + const ctx = canvas.getContext("2d"); 29 + 30 + // draw background 31 + const bgPath = isWater ? "images/interface/background4.jpg" : "images/interface/background2.jpg"; 32 + const bgImg = await loadImage(`${baseUrl}${bgPath}`); 33 + ctx.drawImage(bgImg, -115, 0, 1400, 600); 34 + 35 + // sort by zindex (plant[5]) 36 + thumb.sort((a, b) => a[5] - b[5]); 37 + 38 + // preload all plant images + shadow 39 + const shadowImg = await loadImage(`${baseUrl}images/interface/plantshadow32.png`); 40 + const images = await Promise.all( 41 + thumb.map((plant) => { 42 + const plantName = izombiePlantsMap[plant[0]]; 43 + const data = plantImages[plantName]; 44 + const src = plant[0] !== PUMPKIN_HEAD_INDEX ? data.PicArr[1] : data.PicArr[8]; 45 + return loadImage(`${baseUrl}${src}`); 46 + }) 47 + ); 48 + 49 + // draw plants (game renders at 0.9 scale, so scale from center) 50 + const gameScale = 0.9; 51 + thumb.forEach((plant, i) => { 52 + const w = plant[3] / gameScale; 53 + const h = plant[4] / gameScale; 54 + const x = plant[1] + (plant[3] - w) / 2; 55 + const y = plant[2] + (plant[4] - h) / 2; 56 + 57 + // draw shadow using per-plant getShadow CSS style 58 + const plantName = izombiePlantsMap[plant[0]]; 59 + const data = plantImages[plantName]; 60 + const style = data.shadowStyle; 61 + if (!style.includes("display:none") && !style.includes("display: none")) { 62 + const leftMatch = style.match(/left:\s*(-?[\d.]+)px/); 63 + const topMatch = style.match(/top:\s*(-?[\d.]+)px/); 64 + const shadowLeft = leftMatch ? parseFloat(leftMatch[1]) : 0; 65 + const shadowTop = topMatch ? parseFloat(topMatch[1]) : 0; 66 + ctx.drawImage(shadowImg, x + shadowLeft / gameScale, y + shadowTop / gameScale); 67 + } 68 + 69 + ctx.drawImage(images[i], x, y, w, h); 70 + }); 71 + 72 + return canvas.encode("png"); 73 + }
+27 -21
modules/routes/admin.ts
··· 191 191 } 192 192 193 193 if (changes.length > 0) { 194 - const levelInfo = { 195 - id: levelId, 196 - name: typedUpdatedLevel.name, 197 - author: typedUpdatedLevel.author, 198 - gameUrl: config.gameUrl, 199 - backendUrl: config.backendUrl, 200 - }; 201 - 194 + // update logging messages (fire-and-forget, don't block the response) 202 195 if (typedUpdatedLevel.logging_data) { 203 - await deps.loggingManager.editLevelMessage(typedUpdatedLevel.logging_data, levelInfo); 196 + const levelInfo = { 197 + id: levelId, 198 + name: typedUpdatedLevel.name, 199 + author: typedUpdatedLevel.author, 200 + gameUrl: config.gameUrl, 201 + backendUrl: config.backendUrl, 202 + }; 204 203 205 204 const adminLevelInfo = { 206 205 ...levelInfo, ··· 211 210 dbCtx.createOneTimeTokenForLevel(levelId) 212 211 )}&action=delete&level=${levelId}`, 213 212 }; 214 - await deps.loggingManager.editAdminLevelMessage(typedUpdatedLevel.logging_data, adminLevelInfo); 213 + 214 + Promise.allSettled([ 215 + deps.loggingManager.editLevelMessage(typedUpdatedLevel.logging_data, levelInfo), 216 + deps.loggingManager.editAdminLevelMessage(typedUpdatedLevel.logging_data, adminLevelInfo), 217 + ]).catch((err) => console.error("Warning: Failed to update logging messages for level", levelId, err)); 215 218 } 216 219 217 - await deps.loggingManager.sendAuditLog({ 220 + deps.loggingManager.sendAuditLog({ 218 221 action: "edit", 219 222 levelId, 220 223 levelName: typedUpdatedLevel.name, 221 224 author: typedUpdatedLevel.author, 222 225 changes: changes.join("\n"), 223 - }); 226 + }).catch((err) => console.error("Warning: Failed to send audit log for level edit", levelId, err)); 224 227 } 225 228 226 229 // send to posthog ··· 378 381 379 382 const typedLevel = existingLevel as LevelRecord; 380 383 381 - // delete logging messages if they exist 384 + // delete the level from the database first (critical operation) 385 + dbCtx.db.prepare("DELETE FROM favorites WHERE level_id = ?").run(levelId); 386 + dbCtx.db.prepare("DELETE FROM levels WHERE id = ?").run(levelId); 387 + 388 + // clean up logging messages (fire-and-forget, don't block the response) 382 389 if (typedLevel.logging_data) { 383 - await deps.loggingManager.deleteLevelMessage(typedLevel.logging_data); 384 - await deps.loggingManager.deleteAdminLevelMessage(typedLevel.logging_data); 385 - await deps.loggingManager.deleteFeaturedMessage(typedLevel.logging_data); 390 + Promise.allSettled([ 391 + deps.loggingManager.deleteLevelMessage(typedLevel.logging_data), 392 + deps.loggingManager.deleteAdminLevelMessage(typedLevel.logging_data), 393 + deps.loggingManager.deleteFeaturedMessage(typedLevel.logging_data), 394 + ]).catch((err) => console.error("Warning: Failed to clean up logging messages for level", levelId, err)); 386 395 } 387 396 388 - await deps.loggingManager.sendAuditLog({ 397 + deps.loggingManager.sendAuditLog({ 389 398 action: "delete", 390 399 levelId, 391 400 levelName: typedLevel.name, 392 401 author: typedLevel.author, 393 - }); 394 - 395 - dbCtx.db.prepare("DELETE FROM favorites WHERE level_id = ?").run(levelId); 396 - dbCtx.db.prepare("DELETE FROM levels WHERE id = ?").run(levelId); 402 + }).catch((err) => console.error("Warning: Failed to send audit log for level deletion", levelId, err)); 397 403 398 404 try { 399 405 const fileExtension = `izl${typedLevel.version || 3}`;
+40 -2
modules/routes/levels.ts
··· 1 1 import { getClientIP } from "../request.ts"; 2 2 import { Buffer } from "node:buffer"; 3 - import { allPlantsStringArray, decodeFile, decodeLevelFromDisk, detectFileVersion } from "../levels_io.ts"; 3 + import { allPlantsStringArray, decodeFile, decodeLevelFromDisk, detectFileVersion, encodeIZL3File } from "../levels_io.ts"; 4 4 import { validateClone } from "../validate.ts"; 5 5 import { postHogClient } from "../posthog.ts"; 6 + import { getPlantImages, type PlantData } from "../plantImages.ts"; 7 + import { renderThumbnailCanvas } from "../renderThumbnail.ts"; 6 8 7 9 import type { ServerConfig } from "../config.ts"; 8 10 import type { DbContext, LevelRecord } from "../db.ts"; ··· 20 22 loggingManager: LoggingManager; 21 23 } 22 24 ) { 25 + let plantImagesCache: { [key: string]: PlantData } | null = null; 23 26 const uploadRateLimitByIp = new Map<string, number>(); 24 27 const UPLOAD_WINDOW_MS = 60_000; 25 28 ··· 173 176 }); 174 177 } 175 178 179 + // build thumbnail data before pixel positioning is stripped 180 + const thumbData = cloneData.plants.map((plant: any) => [ 181 + allPlantsStringArray.indexOf(plant.plantName), 182 + plant.eleLeft, 183 + plant.eleTop, 184 + plant.eleWidth, 185 + plant.eleHeight, 186 + plant.zIndex, 187 + ]); 188 + 189 + // re-encode the modified level data 190 + levelBinary = encodeIZL3File(cloneData); 191 + 176 192 // store in database 177 193 const now = Math.floor(Date.now() / 1000); 178 194 dbCtx.db ··· 249 265 } 250 266 251 267 if (config.useUploadLogging && deps.loggingManager.hasProviders) { 268 + // render thumbnail for logging 269 + let thumbnailPng: Uint8Array | undefined; 270 + try { 271 + if (!plantImagesCache) { 272 + plantImagesCache = await getPlantImages(config); 273 + } 274 + thumbnailPng = await renderThumbnailCanvas(thumbData, is_water, plantImagesCache, config); 275 + } catch (e) { 276 + console.error("Failed to render thumbnail for logging:", e); 277 + } 278 + 252 279 const levelInfo = { 253 280 id: levelId, 254 281 name, 255 282 author, 256 283 gameUrl: config.gameUrl, 257 284 backendUrl: config.backendUrl, 285 + thumbnail: thumbnailPng, 258 286 }; 259 287 260 288 const adminLevelInfo = { ··· 597 625 try { 598 626 const fileContent = Deno.readFileSync(filePath); 599 627 628 + // strip position data before sending to client (not needed for gameplay) 629 + const decoded = decodeFile(fileContent); 630 + for (const plant of decoded.plants) { 631 + delete plant.eleLeft; 632 + delete plant.eleTop; 633 + delete plant.eleWidth; 634 + delete plant.eleHeight; 635 + } 636 + const strippedContent = encodeIZL3File(decoded); 637 + 600 638 res.setHeader("Content-Type", "application/octet-stream"); 601 639 res.setHeader("Content-Disposition", `attachment; filename="${typedLevel.name.replace(/[^a-zA-Z0-9]/g, "_")}.${fileExtension}"`); 602 - res.send(Buffer.from(fileContent)); 640 + res.send(Buffer.from(strippedContent)); 603 641 } catch (fileError) { 604 642 console.error("Error reading level file:", fileError); 605 643 res.status(404).json({
+782
openapi.yaml
··· 1 + openapi: 3.0.3 2 + info: 3 + title: PVZM Backend API 4 + description: "API for the Plants vs. Zombies: MODDED level sharing platform. Supports level uploading, downloading, browsing, favoriting, reporting, and admin management." 5 + version: 0.6.0 6 + contact: 7 + url: https://pvzm.net 8 + 9 + servers: 10 + - url: https://backend.pvzm.net 11 + description: Production 12 + 13 + tags: 14 + - name: Health 15 + description: Health check endpoints 16 + - name: Config 17 + description: Server configuration 18 + - name: Levels 19 + description: Public level operations 20 + - name: Auth 21 + description: GitHub OAuth authentication 22 + - name: Admin 23 + description: Admin level management 24 + 25 + paths: 26 + /api/health: 27 + get: 28 + tags: [Health] 29 + summary: Health check 30 + operationId: getHealth 31 + responses: 32 + "200": 33 + description: Server is healthy 34 + content: 35 + application/json: 36 + schema: 37 + type: object 38 + required: [status, timestamp, version] 39 + properties: 40 + status: 41 + type: string 42 + example: ok 43 + timestamp: 44 + type: string 45 + format: date-time 46 + version: 47 + type: string 48 + example: 0.6.0 49 + 50 + /api/config: 51 + get: 52 + tags: [Config] 53 + summary: Get server configuration 54 + operationId: getConfig 55 + responses: 56 + "200": 57 + description: Current server configuration 58 + content: 59 + application/json: 60 + schema: 61 + type: object 62 + required: [turnstileEnabled, turnstileSiteKey, moderationEnabled] 63 + properties: 64 + turnstileEnabled: 65 + type: boolean 66 + turnstileSiteKey: 67 + type: string 68 + nullable: true 69 + moderationEnabled: 70 + type: boolean 71 + 72 + /api/levels: 73 + get: 74 + tags: [Levels] 75 + summary: List levels 76 + description: Retrieve a paginated, filterable list of levels. When `sort=featured`, an author diversity algorithm is applied. 77 + operationId: getLevels 78 + parameters: 79 + - name: page 80 + in: query 81 + schema: 82 + type: integer 83 + default: 1 84 + minimum: 1 85 + - name: limit 86 + in: query 87 + schema: 88 + type: integer 89 + default: 10 90 + minimum: 1 91 + - name: sort 92 + in: query 93 + schema: 94 + type: string 95 + enum: [plays, recent, favorites, featured] 96 + default: plays 97 + - name: reversed_order 98 + in: query 99 + schema: 100 + type: string 101 + enum: ["true", "false"] 102 + default: "false" 103 + - name: author 104 + in: query 105 + description: Filter by author name (partial match) 106 + schema: 107 + type: string 108 + - name: is_water 109 + in: query 110 + schema: 111 + type: string 112 + enum: ["true", "false"] 113 + - name: version 114 + in: query 115 + schema: 116 + type: integer 117 + - name: token 118 + in: query 119 + description: One-time token to fetch a specific level by ID. Ignores all other filters when provided. 120 + schema: 121 + type: string 122 + responses: 123 + "200": 124 + description: Paginated list of levels 125 + content: 126 + application/json: 127 + schema: 128 + type: object 129 + required: [levels, pagination] 130 + properties: 131 + levels: 132 + type: array 133 + items: 134 + $ref: "#/components/schemas/LevelSummary" 135 + pagination: 136 + $ref: "#/components/schemas/Pagination" 137 + "429": 138 + $ref: "#/components/responses/RateLimited" 139 + 140 + post: 141 + tags: [Levels] 142 + summary: Upload a level 143 + description: | 144 + Upload a new level in IZL3 binary format. Rate limited to 1 upload per 60 seconds per IP. 145 + 146 + The request body must be sent as raw binary (Content-Type: application/octet-stream) containing the IZL3 level file. 147 + 148 + Validation includes: IZL3 format check, name/author content moderation (OpenAI + bad-words filter), 149 + plant/zombie placement rules, and optional Turnstile CAPTCHA verification. 150 + operationId: createLevel 151 + parameters: 152 + - name: author 153 + in: query 154 + required: true 155 + description: Author name (max 11 characters) 156 + schema: 157 + type: string 158 + maxLength: 11 159 + - name: turnstileResponse 160 + in: query 161 + description: Cloudflare Turnstile CAPTCHA token (required if Turnstile is enabled) 162 + schema: 163 + type: string 164 + responses: 165 + "201": 166 + description: Level created successfully 167 + content: 168 + application/json: 169 + schema: 170 + type: object 171 + required: [id, name, author, created_at, sun, is_water, version] 172 + properties: 173 + id: 174 + type: integer 175 + name: 176 + type: string 177 + author: 178 + type: string 179 + created_at: 180 + type: integer 181 + description: Unix timestamp 182 + sun: 183 + type: integer 184 + is_water: 185 + type: boolean 186 + version: 187 + type: integer 188 + "400": 189 + $ref: "#/components/responses/BadRequest" 190 + "415": 191 + description: Unsupported media type (Content-Type must be application/octet-stream) 192 + content: 193 + application/json: 194 + schema: 195 + $ref: "#/components/schemas/Error" 196 + "429": 197 + $ref: "#/components/responses/RateLimited" 198 + 199 + /api/levels/{id}: 200 + get: 201 + tags: [Levels] 202 + summary: Get a level by ID 203 + operationId: getLevel 204 + parameters: 205 + - $ref: "#/components/parameters/LevelId" 206 + responses: 207 + "200": 208 + description: Level details 209 + content: 210 + application/json: 211 + schema: 212 + $ref: "#/components/schemas/LevelSummary" 213 + "400": 214 + $ref: "#/components/responses/BadRequest" 215 + "404": 216 + $ref: "#/components/responses/NotFound" 217 + 218 + /api/levels/{id}/download: 219 + get: 220 + tags: [Levels] 221 + summary: Download a level file 222 + description: Downloads the IZL3 level file. Increments the play count. Rate limited to 5 downloads per 5 seconds per IP. 223 + operationId: downloadLevel 224 + parameters: 225 + - $ref: "#/components/parameters/LevelId" 226 + responses: 227 + "200": 228 + description: Level file download 229 + headers: 230 + Content-Disposition: 231 + schema: 232 + type: string 233 + example: attachment; filename="MyLevel.izl3" 234 + content: 235 + application/octet-stream: 236 + schema: 237 + type: string 238 + format: binary 239 + "400": 240 + $ref: "#/components/responses/BadRequest" 241 + "404": 242 + $ref: "#/components/responses/NotFound" 243 + "429": 244 + $ref: "#/components/responses/RateLimited" 245 + 246 + /api/levels/{id}/report: 247 + post: 248 + tags: [Levels] 249 + summary: Report a level 250 + operationId: reportLevel 251 + parameters: 252 + - $ref: "#/components/parameters/LevelId" 253 + requestBody: 254 + required: true 255 + content: 256 + application/json: 257 + schema: 258 + type: object 259 + required: [reason] 260 + properties: 261 + reason: 262 + type: string 263 + description: Description of why the level is being reported 264 + responses: 265 + "200": 266 + description: Report submitted 267 + content: 268 + application/json: 269 + schema: 270 + type: object 271 + required: [success] 272 + properties: 273 + success: 274 + type: boolean 275 + example: true 276 + "404": 277 + $ref: "#/components/responses/NotFound" 278 + 279 + /api/levels/{id}/favorite: 280 + post: 281 + tags: [Levels] 282 + summary: Toggle favorite on a level 283 + description: Toggles the favorite state for the requesting IP. Rate limited to 30 actions per 10 seconds per IP. 284 + operationId: toggleFavorite 285 + parameters: 286 + - $ref: "#/components/parameters/LevelId" 287 + responses: 288 + "200": 289 + description: Favorite toggled 290 + content: 291 + application/json: 292 + schema: 293 + type: object 294 + required: [success, level] 295 + properties: 296 + success: 297 + type: boolean 298 + example: true 299 + level: 300 + type: object 301 + required: [id, name, author, favorites] 302 + properties: 303 + id: 304 + type: integer 305 + name: 306 + type: string 307 + author: 308 + type: string 309 + favorites: 310 + type: integer 311 + "400": 312 + $ref: "#/components/responses/BadRequest" 313 + "404": 314 + $ref: "#/components/responses/NotFound" 315 + "429": 316 + $ref: "#/components/responses/RateLimited" 317 + 318 + # --- Auth --- 319 + 320 + /api/auth/github: 321 + get: 322 + tags: [Auth] 323 + summary: Initiate GitHub OAuth login 324 + description: Redirects the user to GitHub for OAuth authentication. 325 + operationId: githubLogin 326 + responses: 327 + "302": 328 + description: Redirect to GitHub OAuth 329 + 330 + /api/auth/github/callback: 331 + get: 332 + tags: [Auth] 333 + summary: GitHub OAuth callback 334 + description: Handles the OAuth callback from GitHub. Redirects to `/admin.html` on success. 335 + operationId: githubCallback 336 + parameters: 337 + - name: code 338 + in: query 339 + schema: 340 + type: string 341 + responses: 342 + "302": 343 + description: Redirect to admin page on success 344 + 345 + /api/auth/status: 346 + get: 347 + tags: [Auth] 348 + summary: Check authentication status 349 + operationId: getAuthStatus 350 + responses: 351 + "200": 352 + description: Authentication status 353 + content: 354 + application/json: 355 + schema: 356 + oneOf: 357 + - type: object 358 + required: [authenticated, user] 359 + properties: 360 + authenticated: 361 + type: boolean 362 + enum: [true] 363 + user: 364 + type: object 365 + required: [username, displayName, profileUrl, avatarUrl] 366 + properties: 367 + username: 368 + type: string 369 + displayName: 370 + type: string 371 + profileUrl: 372 + type: string 373 + avatarUrl: 374 + type: string 375 + - type: object 376 + required: [authenticated] 377 + properties: 378 + authenticated: 379 + type: boolean 380 + enum: [false] 381 + 382 + /api/auth/logout: 383 + get: 384 + tags: [Auth] 385 + summary: Logout 386 + operationId: logout 387 + responses: 388 + "200": 389 + description: Logged out successfully 390 + 391 + # --- Admin --- 392 + 393 + /api/admin/levels: 394 + get: 395 + tags: [Admin] 396 + summary: List levels (admin) 397 + description: Paginated level list with search. Requires GitHub OAuth. 398 + operationId: getAdminLevels 399 + security: 400 + - githubOAuth: [] 401 + parameters: 402 + - name: page 403 + in: query 404 + schema: 405 + type: integer 406 + default: 1 407 + - name: limit 408 + in: query 409 + schema: 410 + type: integer 411 + default: 10 412 + - name: q 413 + in: query 414 + description: Search query (searches name, author, ID) 415 + schema: 416 + type: string 417 + responses: 418 + "200": 419 + description: Admin level listing 420 + content: 421 + application/json: 422 + schema: 423 + type: object 424 + required: [levels, total, page, limit, totalPages] 425 + properties: 426 + levels: 427 + type: array 428 + items: 429 + $ref: "#/components/schemas/LevelRecord" 430 + total: 431 + type: integer 432 + page: 433 + type: integer 434 + limit: 435 + type: integer 436 + totalPages: 437 + type: integer 438 + "401": 439 + $ref: "#/components/responses/Unauthorized" 440 + 441 + /api/admin/levels/{id}: 442 + put: 443 + tags: [Admin] 444 + summary: Update a level 445 + description: Update level metadata. Requires GitHub OAuth or a valid one-time token. 446 + operationId: updateLevel 447 + security: 448 + - githubOAuth: [] 449 + - oneTimeToken: [] 450 + parameters: 451 + - $ref: "#/components/parameters/LevelId" 452 + - name: token 453 + in: query 454 + description: One-time admin token (alternative to OAuth) 455 + schema: 456 + type: string 457 + requestBody: 458 + required: true 459 + content: 460 + application/json: 461 + schema: 462 + type: object 463 + properties: 464 + name: 465 + type: string 466 + author: 467 + type: string 468 + sun: 469 + type: integer 470 + is_water: 471 + type: integer 472 + enum: [0, 1] 473 + difficulty: 474 + type: integer 475 + favorites: 476 + type: integer 477 + plays: 478 + type: integer 479 + featured: 480 + type: integer 481 + enum: [0, 1] 482 + featured_at: 483 + type: integer 484 + nullable: true 485 + responses: 486 + "200": 487 + description: Level updated 488 + content: 489 + application/json: 490 + schema: 491 + type: object 492 + required: [success, level] 493 + properties: 494 + success: 495 + type: boolean 496 + example: true 497 + level: 498 + $ref: "#/components/schemas/LevelRecord" 499 + "400": 500 + $ref: "#/components/responses/BadRequest" 501 + "401": 502 + $ref: "#/components/responses/Unauthorized" 503 + "404": 504 + $ref: "#/components/responses/NotFound" 505 + 506 + delete: 507 + tags: [Admin] 508 + summary: Delete a level 509 + description: Permanently deletes a level, its file, and all associated data. Requires GitHub OAuth or a valid one-time token. 510 + operationId: deleteLevel 511 + security: 512 + - githubOAuth: [] 513 + - oneTimeToken: [] 514 + parameters: 515 + - $ref: "#/components/parameters/LevelId" 516 + - name: token 517 + in: query 518 + description: One-time admin token (alternative to OAuth) 519 + schema: 520 + type: string 521 + responses: 522 + "200": 523 + description: Level deleted 524 + content: 525 + application/json: 526 + schema: 527 + type: object 528 + required: [success] 529 + properties: 530 + success: 531 + type: boolean 532 + example: true 533 + "400": 534 + $ref: "#/components/responses/BadRequest" 535 + "401": 536 + $ref: "#/components/responses/Unauthorized" 537 + "404": 538 + $ref: "#/components/responses/NotFound" 539 + 540 + /api/admin/levels/{id}/token: 541 + post: 542 + tags: [Admin] 543 + summary: Generate a one-time token for a level 544 + description: Creates a single-use token scoped to a specific level, allowing unauthenticated edit/delete access. 545 + operationId: generateToken 546 + security: 547 + - githubOAuth: [] 548 + parameters: 549 + - $ref: "#/components/parameters/LevelId" 550 + responses: 551 + "200": 552 + description: Token generated 553 + content: 554 + application/json: 555 + schema: 556 + type: object 557 + required: [token, level_id] 558 + properties: 559 + token: 560 + type: string 561 + example: token_abc123... 562 + level_id: 563 + type: integer 564 + "400": 565 + $ref: "#/components/responses/BadRequest" 566 + "401": 567 + $ref: "#/components/responses/Unauthorized" 568 + "404": 569 + $ref: "#/components/responses/NotFound" 570 + 571 + /api/admin/levels/{id}/feature: 572 + post: 573 + tags: [Admin] 574 + summary: Feature a level 575 + description: Marks a level as featured. Requires GitHub OAuth. 576 + operationId: featureLevel 577 + security: 578 + - githubOAuth: [] 579 + parameters: 580 + - $ref: "#/components/parameters/LevelId" 581 + responses: 582 + "200": 583 + description: Level featured 584 + content: 585 + application/json: 586 + schema: 587 + type: object 588 + required: [success, level] 589 + properties: 590 + success: 591 + type: boolean 592 + example: true 593 + level: 594 + $ref: "#/components/schemas/LevelRecord" 595 + "400": 596 + $ref: "#/components/responses/BadRequest" 597 + "401": 598 + $ref: "#/components/responses/Unauthorized" 599 + "404": 600 + $ref: "#/components/responses/NotFound" 601 + 602 + delete: 603 + tags: [Admin] 604 + summary: Unfeature a level 605 + description: Removes the featured status from a level. Requires GitHub OAuth. 606 + operationId: unfeatureLevel 607 + security: 608 + - githubOAuth: [] 609 + parameters: 610 + - $ref: "#/components/parameters/LevelId" 611 + responses: 612 + "200": 613 + description: Level unfeatured 614 + content: 615 + application/json: 616 + schema: 617 + type: object 618 + required: [success, level] 619 + properties: 620 + success: 621 + type: boolean 622 + example: true 623 + level: 624 + $ref: "#/components/schemas/LevelRecord" 625 + "400": 626 + $ref: "#/components/responses/BadRequest" 627 + "401": 628 + $ref: "#/components/responses/Unauthorized" 629 + "404": 630 + $ref: "#/components/responses/NotFound" 631 + 632 + components: 633 + securitySchemes: 634 + githubOAuth: 635 + type: http 636 + scheme: bearer 637 + description: GitHub OAuth2 session-based authentication (via browser cookies) 638 + oneTimeToken: 639 + type: apiKey 640 + in: query 641 + name: token 642 + description: Single-use token scoped to a specific level ID 643 + 644 + parameters: 645 + LevelId: 646 + name: id 647 + in: path 648 + required: true 649 + schema: 650 + type: integer 651 + description: Level ID 652 + 653 + schemas: 654 + LevelSummary: 655 + type: object 656 + required: [id, name, author, created_at, sun, is_water, favorites, plays, difficulty, version, featured, featured_at] 657 + properties: 658 + id: 659 + type: integer 660 + name: 661 + type: string 662 + author: 663 + type: string 664 + created_at: 665 + type: integer 666 + description: Unix timestamp 667 + sun: 668 + type: integer 669 + is_water: 670 + type: boolean 671 + favorites: 672 + type: integer 673 + plays: 674 + type: integer 675 + difficulty: 676 + type: integer 677 + version: 678 + type: integer 679 + featured: 680 + type: integer 681 + enum: [0, 1] 682 + featured_at: 683 + type: integer 684 + nullable: true 685 + description: Unix timestamp 686 + thumbnail: 687 + type: array 688 + nullable: true 689 + description: Array of plant placement tuples [plantIndex, eleLeft, eleTop, eleWidth, eleHeight, zIndex] 690 + items: 691 + type: array 692 + items: 693 + type: number 694 + 695 + LevelRecord: 696 + type: object 697 + required: [id, name, author, sun, is_water, difficulty, favorites, plays, version, featured, featured_at, logging_data] 698 + properties: 699 + id: 700 + type: integer 701 + name: 702 + type: string 703 + author: 704 + type: string 705 + sun: 706 + type: integer 707 + is_water: 708 + type: integer 709 + enum: [0, 1] 710 + difficulty: 711 + type: integer 712 + favorites: 713 + type: integer 714 + plays: 715 + type: integer 716 + version: 717 + type: integer 718 + featured: 719 + type: integer 720 + enum: [0, 1] 721 + featured_at: 722 + type: integer 723 + nullable: true 724 + logging_data: 725 + type: string 726 + nullable: true 727 + description: JSON string containing Discord/Bluesky message IDs for logging management 728 + 729 + Pagination: 730 + type: object 731 + required: [total, page, limit, pages] 732 + properties: 733 + total: 734 + type: integer 735 + page: 736 + type: integer 737 + limit: 738 + type: integer 739 + pages: 740 + type: integer 741 + 742 + Error: 743 + type: object 744 + required: [error, message] 745 + properties: 746 + error: 747 + type: string 748 + message: 749 + type: string 750 + retryAfterSeconds: 751 + type: number 752 + description: Present on 429 responses 753 + 754 + responses: 755 + BadRequest: 756 + description: Invalid input or validation failure 757 + content: 758 + application/json: 759 + schema: 760 + $ref: "#/components/schemas/Error" 761 + NotFound: 762 + description: Resource not found 763 + content: 764 + application/json: 765 + schema: 766 + $ref: "#/components/schemas/Error" 767 + Unauthorized: 768 + description: Authentication required 769 + content: 770 + application/json: 771 + schema: 772 + $ref: "#/components/schemas/Error" 773 + RateLimited: 774 + description: Too many requests 775 + headers: 776 + Retry-After: 777 + schema: 778 + type: integer 779 + content: 780 + application/json: 781 + schema: 782 + $ref: "#/components/schemas/Error"