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.

v0.2.0 - modifying a levels metadata now also affects the level data - if github auth is disabled, dont show a login prompt on the admin page - fix incorrect plants map - clean up decoder/encoder

Clay 4e923026 d80a5e53

+508 -390
+1 -1
README.md
··· 1 1 # PVZM Backend 2 2 3 - > v0.1.1 3 + > v0.2.0 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
+1 -1
TODO.md
··· 23 23 - Implement API key authentication for programmatic access 24 24 - Add request size limits for file uploads 25 25 - Consider adding CSRF protection for admin endpoints 26 - - _(Handled by Cloudflare)_ ~~Add rate limiting for API endpoints (especially `/api/levels` POST)~~ 26 + - Add rate limiting for API endpoints (especially `/api/levels` POST) 27 27 28 28 - [ ] **Database Improvements** 29 29 - Add database migrations system for schema changes
+12 -12
main.ts
··· 1 - import { createExpressApp, setupBodyParsers, setupCors } from "./modules/server/app_middleware.ts"; 2 - import { ensureAuthenticated, ensureAuthenticatedOrConsumeTokenForLevelParam, setupGithubAuth, setupSession } from "./modules/server/auth.ts"; 3 - import { loadConfig } from "./modules/server/config.ts"; 4 - import { createDiscordClients } from "./modules/server/discord.ts"; 5 - import { ensureDataFolder, initDatabase } from "./modules/server/db.ts"; 6 - import { initModeration } from "./modules/server/moderation.ts"; 7 - import { setupPublicFolder } from "./modules/server/public_folder.ts"; 8 - import { registerAdminRoutes } from "./modules/server/routes/admin.ts"; 9 - import { registerConfigRoute } from "./modules/server/routes/config.ts"; 10 - import { registerLevelRoutes } from "./modules/server/routes/levels.ts"; 11 - import { registerRootRoute } from "./modules/server/routes/root.ts"; 12 - import { initTurnstile } from "./modules/server/turnstile.ts"; 1 + import { createExpressApp, setupBodyParsers, setupCors } from "./modules/app_middleware.ts"; 2 + import { ensureAuthenticated, ensureAuthenticatedOrConsumeTokenForLevelParam, setupGithubAuth, setupSession } from "./modules/auth.ts"; 3 + import { loadConfig } from "./modules/config.ts"; 4 + import { createDiscordClients } from "./modules/discord.ts"; 5 + import { ensureDataFolder, initDatabase } from "./modules/db.ts"; 6 + import { initModeration } from "./modules/moderation.ts"; 7 + import { setupPublicFolder } from "./modules/public_folder.ts"; 8 + import { registerAdminRoutes } from "./modules/routes/admin.ts"; 9 + import { registerConfigRoute } from "./modules/routes/config.ts"; 10 + import { registerLevelRoutes } from "./modules/routes/levels.ts"; 11 + import { registerRootRoute } from "./modules/routes/root.ts"; 12 + import { initTurnstile } from "./modules/turnstile.ts"; 13 13 14 14 const config = loadConfig(); 15 15
-262
modules/decode.ts
··· 1 - import pako from "pako"; 2 - import { decode as msgpackDecode } from "@msgpack/msgpack"; 3 - 4 - interface Plant { 5 - zIndex: number; 6 - plantRow: number; 7 - plantCol: number; 8 - plantName: string; 9 - eleLeft?: number; 10 - eleTop?: number; 11 - eleWidth?: number; 12 - eleHeight?: number; 13 - } 14 - 15 - interface Clone { 16 - plants: Plant[]; 17 - music: string; 18 - sun: number; 19 - name: string; 20 - lfValue: number[]; 21 - stripeCol: number; 22 - screenshot?: string; 23 - } 24 - 25 - const TINYIFIER_MAP = { 26 - // main 27 - lfValue: 1, 28 - music: 2, 29 - name: 3, 30 - plants: 4, 31 - screenshot: 5, 32 - stripeCol: 6, 33 - sun: 7, 34 - // plants 35 - plantCol: 8, 36 - plantName: 9, 37 - plantRow: 10, 38 - zIndex: 11, 39 - eleLeft: 12, 40 - eleTop: 13, 41 - eleWidth: 14, 42 - eleHeight: 15, 43 - }; 44 - 45 - const REVERSE_TINYIFIER_MAP: Record<number, string> = Object.fromEntries(Object.entries(TINYIFIER_MAP).map(([key, value]) => [value, key])); 46 - // plant names array matching the frontend 47 - export const allPlantsStringArray = [ 48 - "oPeashooter", 49 - "oSunFlower", 50 - "oCherryBomb", 51 - "oWallNut", 52 - "oPotatoMine", 53 - "oSnowPea", 54 - "oChomper", 55 - "oRepeater", 56 - "oPuffShroom", 57 - "oSunShroom", 58 - "oFumeShroom", 59 - "oGraveBuster", 60 - "oHypnoShroom", 61 - "oScaredyShroom", 62 - "oIceShroom", 63 - "oDoomShroom", 64 - "oLilyPad", 65 - "oSquash", 66 - "oThreepeater", 67 - "oTangleKlep", 68 - "oJalapeno", 69 - "oSpikeweed", 70 - "oTorchwood", 71 - "oTallNut", 72 - "oCactus", 73 - "oPlantern", 74 - "oSplitPea", 75 - "oStarfruit", 76 - "oPumpkinHead", 77 - "oFlowerPot", 78 - "oCoffeeBean", 79 - "oGarlic", 80 - "oSeaShroom", 81 - "oOxygen", 82 - "ostar", 83 - "oTTS", 84 - "oGun", 85 - "oSeaAnemone", 86 - "oGatlingPea", 87 - "oGloomShroom", 88 - "oTwinSunflower", 89 - "oSpikerock", 90 - "oTenManNut", 91 - "oSnowRepeater", 92 - "oCattail", 93 - "oLotusRoot", 94 - "oIceFumeShroom", 95 - "oLaserBean", 96 - "oBigChomper", 97 - "oFlamesMushroom", 98 - ]; 99 - 100 - function unpackToArray(packed: number): number[] { 101 - // extract the length from bits 14-15 102 - const lengthBits = (packed >> 14) & 3; 103 - const length = lengthBits + 6; // 0 -> 6, 1 -> 7 104 - 105 - const arr: number[] = []; 106 - for (let i = 0; i < length; i++) { 107 - // extract 2 bits at position i*2 using mask 0b11 (3 in decimal) 108 - const value = (packed >> (i * 2)) & 3; 109 - arr.push(value); 110 - } 111 - return arr; 112 - } 113 - 114 - function decompressStringFromBytes(compressed: Uint8Array | ArrayBuffer | number[]): string { 115 - const inputData = Array.isArray(compressed) ? new Uint8Array(compressed) : compressed instanceof ArrayBuffer ? new Uint8Array(compressed) : compressed; 116 - 117 - const decompressed = pako.inflate(inputData); 118 - return new TextDecoder().decode(decompressed); 119 - } 120 - 121 - function maybeInflateZlibBytes(bytes: Uint8Array): Uint8Array { 122 - // zlib-wrapped deflate commonly starts with 0x78 (e.g. 0x78 0x9C / 0xDA / 0x01 / 0x5E) 123 - if (bytes.length >= 2 && bytes[0] === 0x78) { 124 - try { 125 - return pako.inflate(bytes); 126 - } catch { 127 - // not actually zlib/deflate; treat as already-uncompressed msgpack. 128 - } 129 - } 130 - return bytes; 131 - } 132 - 133 - function reverseKeys(obj: any): any { 134 - if (obj instanceof Map) { 135 - // msgpack decode may produce Maps depending on options/inputs 136 - const result: any = {}; 137 - for (const [k, v] of obj.entries()) { 138 - const keyStr = String(k); 139 - const mapped = REVERSE_TINYIFIER_MAP[Number(keyStr)] ?? keyStr; 140 - 141 - if (mapped === "plantName" && typeof v === "number") { 142 - result[mapped] = allPlantsStringArray[v] || v; 143 - } else { 144 - result[mapped] = reverseKeys(v); 145 - } 146 - } 147 - return result; 148 - } 149 - 150 - if (Array.isArray(obj)) { 151 - return obj.map((item) => reverseKeys(item)); 152 - } else if (obj !== null && typeof obj === "object") { 153 - const result: any = {}; 154 - for (const [key, value] of Object.entries(obj)) { 155 - // frontend does REVERSE_TINYIFIER_MAP[key]; ensure numeric-string keys map correctly 156 - const mapped = REVERSE_TINYIFIER_MAP[Number(key)] ?? key; 157 - 158 - if (mapped === "plantName" && typeof value === "number") { 159 - result[mapped] = allPlantsStringArray[value] || value; 160 - } else { 161 - result[mapped] = reverseKeys(value); 162 - } 163 - } 164 - return result; 165 - } 166 - return obj; 167 - } 168 - 169 - function untinyifyClone(tinyBytes: Uint8Array): Clone { 170 - // be tolerant: some paths may still pass deflated bytes. 171 - const msgpackBytes = maybeInflateZlibBytes(tinyBytes); 172 - 173 - const obj = msgpackDecode(msgpackBytes); 174 - const reversed = reverseKeys(obj); 175 - 176 - if (reversed.lfValue !== undefined && typeof reversed.lfValue === "number") { 177 - reversed.lfValue = unpackToArray(reversed.lfValue); 178 - } 179 - return reversed; 180 - } 181 - 182 - function untinyifyClone_OLD(tinyString: string): Clone { 183 - const originalClone: Partial<Clone> = {}; 184 - const pairs = tinyString.split("\uE006"); 185 - 186 - for (const pair of pairs) { 187 - const [tinyKey, tinyValue] = pair.split("\uE005"); 188 - const originalKey = REVERSE_TINYIFIER_MAP[parseInt(tinyKey, 10)]; 189 - 190 - if (!originalKey) continue; 191 - 192 - if (originalKey === "plants") { 193 - const plants: Plant[] = []; 194 - const plantStrings = tinyValue.split("\uE003"); 195 - for (const plantString of plantStrings) { 196 - if (!plantString) continue; 197 - 198 - const plantObj: Record<string, any> = {}; 199 - const plantData = plantString.slice(1, -1).split("\uE002"); 200 - 201 - for (const plantPair of plantData) { 202 - const [plantTinyKey, plantValueStr] = plantPair.split("\uE004"); 203 - const plantOriginalKey = REVERSE_TINYIFIER_MAP[parseInt(plantTinyKey, 10)] as keyof Plant; 204 - 205 - if (!plantOriginalKey) continue; 206 - 207 - // match frontend OLD behavior: only these are forced numeric 208 - if (["zIndex", "plantRow", "plantCol"].includes(plantOriginalKey)) { 209 - plantObj[plantOriginalKey] = parseInt(plantValueStr, 10); 210 - } else { 211 - plantObj[plantOriginalKey] = plantValueStr; 212 - } 213 - } 214 - 215 - plants.push(plantObj as Plant); 216 - } 217 - originalClone[originalKey] = plants; 218 - } else if (originalKey === "lfValue") { 219 - originalClone[originalKey] = tinyValue.split("\uE000").map(Number); 220 - } else if (["sun", "stripeCol"].includes(originalKey)) { 221 - originalClone[originalKey as "sun" | "stripeCol"] = parseInt(tinyValue, 10); 222 - } else { 223 - originalClone[originalKey as keyof Clone] = tinyValue as any; 224 - } 225 - } 226 - return originalClone as Clone; 227 - } 228 - 229 - export function decodeFile(compressedData: Uint8Array | ArrayBuffer | number[]): Clone { 230 - const fileBytes = Array.isArray(compressedData) ? new Uint8Array(compressedData) : new Uint8Array(compressedData); 231 - 232 - // check if first 4 bytes are "IZL3" (49 5A 4C 33) 233 - const izl3Header = new Uint8Array([0x49, 0x5a, 0x4c, 0x33]); 234 - const fileHeader = fileBytes.slice(0, 4); 235 - 236 - if ( 237 - fileHeader.length >= 4 && 238 - fileHeader[0] === izl3Header[0] && 239 - fileHeader[1] === izl3Header[1] && 240 - fileHeader[2] === izl3Header[2] && 241 - fileHeader[3] === izl3Header[3] 242 - ) { 243 - // IZL3 format - compressed msgpack bytes after the header. 244 - // decode directly from bytes (no base64 roundtrip). 245 - const payload = fileBytes.slice(4); 246 - 247 - // most IZL3 payloads are deflated msgpack, but tolerate already-msgpack payloads too. 248 - const maybeMsgpack = maybeInflateZlibBytes(payload); 249 - return untinyifyClone(maybeMsgpack); 250 - } 251 - 252 - // frontend: if first byte is 0x78, treat as deflate old-format (no extra heuristics) 253 - if (fileBytes.length >= 2 && fileBytes[0] === 0x78) { 254 - const decompressedString = decompressStringFromBytes(fileBytes); 255 - return untinyifyClone_OLD(decompressedString); 256 - } 257 - 258 - // old format - treat as string data 259 - const decompressedString = decompressStringFromBytes(fileBytes); 260 - const cloneData = untinyifyClone_OLD(decompressedString); 261 - return cloneData; 262 - }
+403
modules/levels_io.ts
··· 1 + import pako from "pako"; 2 + import { decode as msgpackDecode, encode as msgpackEncode } from "@msgpack/msgpack"; 3 + 4 + export interface Plant { 5 + zIndex: number; 6 + plantRow: number; 7 + plantCol: number; 8 + plantName: string; 9 + eleLeft?: number; 10 + eleTop?: number; 11 + eleWidth?: number; 12 + eleHeight?: number; 13 + } 14 + 15 + export interface Clone { 16 + plants: Plant[]; 17 + music: string; 18 + sun: number; 19 + name: string; 20 + lfValue: number[]; 21 + stripeCol?: number; 22 + screenshot?: string; 23 + } 24 + 25 + export type CloneLike = Clone; 26 + 27 + export const IZL3_HEADER = new Uint8Array([0x49, 0x5a, 0x4c, 0x33]); // "IZL3" 28 + 29 + type DecodeLevelResult = 30 + | { decoded: ReturnType<typeof decodeFile>; decodeError: null } 31 + | { decoded: null; decodeError: string }; 32 + 33 + export async function decodeLevelFromDisk(dataFolderPath: string, levelId: number, version: number): Promise<DecodeLevelResult> { 34 + try { 35 + const fileExtension = `izl${version === 1 ? "" : version}`; 36 + const filePath = `${dataFolderPath}/${levelId}.${fileExtension}`; 37 + const fileBytes = await Deno.readFile(filePath); 38 + return { decoded: decodeFile(fileBytes), decodeError: null }; 39 + } catch (error) { 40 + return { 41 + decoded: null, 42 + decodeError: (error as Error).message || String(error), 43 + }; 44 + } 45 + } 46 + 47 + const TINYIFIER_MAP = { 48 + // main 49 + lfValue: 1, 50 + music: 2, 51 + name: 3, 52 + plants: 4, 53 + screenshot: 5, 54 + stripeCol: 6, 55 + sun: 7, 56 + // plants 57 + plantCol: 8, 58 + plantName: 9, 59 + plantRow: 10, 60 + zIndex: 11, 61 + eleLeft: 12, 62 + eleTop: 13, 63 + eleWidth: 14, 64 + eleHeight: 15, 65 + }; 66 + 67 + const REVERSE_TINYIFIER_MAP: Record<number, string> = Object.fromEntries( 68 + Object.entries(TINYIFIER_MAP).map(([key, value]) => [value, key]) 69 + ); 70 + 71 + export const allPlantsStringArray = [ 72 + "oPeashooter", 73 + "oSunFlower", 74 + "oCherryBomb", 75 + "oWallNut", 76 + "oPotatoMine", 77 + "oSnowPea", 78 + "oChomper", 79 + "oRepeater", 80 + "oPuffShroom", 81 + "oSunShroom", 82 + "oFumeShroom", 83 + "oGraveBuster", 84 + "oHypnoShroom", 85 + "oScaredyShroom", 86 + "oIceShroom", 87 + "oDoomShroom", 88 + "oLilyPad", 89 + "oILilyPad", 90 + "oSquash", 91 + "oThreepeater", 92 + "oTangleKlep", 93 + "oJalapeno", 94 + "oSpikeweed", 95 + "oTorchwood", 96 + "oTallNut", 97 + "oCactus", 98 + "oPlantern", 99 + "oSplitPea", 100 + "oStarfruit", 101 + "oPumpkinHead", 102 + "oFlowerPot", 103 + "oCoffeeBean", 104 + "oGarlic", 105 + "oSeaShroom", 106 + "oOxygen", 107 + "ostar", 108 + "oTTS", 109 + "oGun", 110 + "oSeaAnemone", 111 + "oGatlingPea", 112 + "oGloomShroom", 113 + "oTwinSunflower", 114 + "oSpikerock", 115 + "oTenManNut", 116 + "oSnowRepeater", 117 + "oCattail", 118 + "oLotusRoot", 119 + "oIceFumeShroom", 120 + "oLaserBean", 121 + "oBigChomper", 122 + "oFlamesMushroom", 123 + ]; 124 + 125 + // Client name for this list (kept as alias for API familiarity) 126 + export const izombiePlantsMap = allPlantsStringArray; 127 + 128 + function packFromArray(arr: number[]): number { 129 + if (arr.length < 6 || arr.length > 9) { 130 + throw new Error(`lfValue must contain 6-9 elements, got ${arr.length}`); 131 + } 132 + 133 + let packed = 0; 134 + for (let i = 0; i < arr.length; i++) { 135 + const value = arr[i]; 136 + if (!Number.isInteger(value) || value < 0 || value > 3) { 137 + throw new Error(`lfValue[${i}] must be an integer 0-3, got ${value}`); 138 + } 139 + packed |= value << (i * 2); 140 + } 141 + 142 + const lengthBits = (arr.length - 6) << 14; 143 + packed |= lengthBits; 144 + 145 + return packed; 146 + } 147 + 148 + function unpackToArray(packed: number): number[] { 149 + const lengthBits = (packed >> 14) & 3; 150 + const length = lengthBits + 6; // 0 -> 6, 1 -> 7, 2 -> 8, 3 -> 9 151 + 152 + const arr: number[] = []; 153 + for (let i = 0; i < length; i++) { 154 + const value = (packed >> (i * 2)) & 3; 155 + arr.push(value); 156 + } 157 + return arr; 158 + } 159 + 160 + function tinyifyKeys(obj: any): any { 161 + if (Array.isArray(obj)) { 162 + return obj.map((item) => tinyifyKeys(item)); 163 + } 164 + 165 + if (obj !== null && typeof obj === "object") { 166 + const result: any = {}; 167 + for (const [key, value] of Object.entries(obj)) { 168 + const mappedKey = (TINYIFIER_MAP as any)[key] ?? key; 169 + 170 + if (key === "lfValue" && Array.isArray(value)) { 171 + result[mappedKey] = packFromArray(value as number[]); 172 + continue; 173 + } 174 + 175 + if (key === "plantName" && typeof value === "string") { 176 + const plantIndex = allPlantsStringArray.indexOf(value); 177 + result[mappedKey] = plantIndex !== -1 ? plantIndex : value; 178 + continue; 179 + } 180 + 181 + result[mappedKey] = tinyifyKeys(value); 182 + } 183 + return result; 184 + } 185 + 186 + return obj; 187 + } 188 + 189 + function reverseKeys(obj: any): any { 190 + if (obj instanceof Map) { 191 + const result: any = {}; 192 + for (const [k, v] of obj.entries()) { 193 + const keyStr = String(k); 194 + const mapped = REVERSE_TINYIFIER_MAP[Number(keyStr)] ?? keyStr; 195 + 196 + if (mapped === "plantName" && typeof v === "number") { 197 + result[mapped] = allPlantsStringArray[v] || v; 198 + } else { 199 + result[mapped] = reverseKeys(v); 200 + } 201 + } 202 + return result; 203 + } 204 + 205 + if (Array.isArray(obj)) { 206 + return obj.map((item) => reverseKeys(item)); 207 + } 208 + 209 + if (obj !== null && typeof obj === "object") { 210 + const result: any = {}; 211 + for (const [key, value] of Object.entries(obj)) { 212 + const mapped = REVERSE_TINYIFIER_MAP[Number(key)] ?? key; 213 + 214 + if (mapped === "plantName" && typeof value === "number") { 215 + result[mapped] = allPlantsStringArray[value] || value; 216 + } else { 217 + result[mapped] = reverseKeys(value); 218 + } 219 + } 220 + return result; 221 + } 222 + 223 + return obj; 224 + } 225 + 226 + function decodeIZL3Bytes(deflatedMsgpack: Uint8Array): CloneLike { 227 + const msgpackBytes = pako.inflate(deflatedMsgpack); 228 + 229 + const obj = msgpackDecode(msgpackBytes); 230 + const reversed = reverseKeys(obj); 231 + 232 + if (reversed.lfValue !== undefined && typeof reversed.lfValue === "number") { 233 + reversed.lfValue = unpackToArray(reversed.lfValue); 234 + } 235 + 236 + return reversed; 237 + } 238 + 239 + function encodeIZL3Payload(levelData: CloneLike): Uint8Array { 240 + const tinyified = tinyifyKeys(levelData); 241 + const msgpackBytes = msgpackEncode(tinyified); 242 + return pako.deflate(msgpackBytes, { level: 9 }); 243 + } 244 + 245 + export function encodeIZL3File(levelData: CloneLike): Uint8Array { 246 + const payload = encodeIZL3Payload(levelData); 247 + const out = new Uint8Array(IZL3_HEADER.length + payload.length); 248 + out.set(IZL3_HEADER, 0); 249 + out.set(payload, IZL3_HEADER.length); 250 + return out; 251 + } 252 + 253 + export function encodeIZL3FileToDisk(dataFolderPath: string, levelId: number, levelData: CloneLike): Promise<void> { 254 + const fileExtension = `izl3`; 255 + const filePath = `${dataFolderPath}/${levelId}.${fileExtension}`; 256 + const fileBytes = encodeIZL3File(levelData); 257 + return Deno.writeFile(filePath, fileBytes); 258 + } 259 + 260 + function bytesToBase64(bytes: Uint8Array): string { 261 + let bin = ""; 262 + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); 263 + return globalThis.btoa(bin); 264 + } 265 + 266 + export function encodeIZL3String(levelData: CloneLike): string { 267 + const payload = encodeIZL3Payload(levelData); 268 + return "|" + bytesToBase64(payload); 269 + } 270 + 271 + function untinyifyClone_IZL2(tinyString: string): CloneLike { 272 + const originalClone: Record<string, unknown> = {}; 273 + const pairs = tinyString.split("\uE006"); 274 + 275 + for (const pair of pairs) { 276 + const [tinyKey, tinyValue] = pair.split("\uE005"); 277 + const originalKey = REVERSE_TINYIFIER_MAP[Number(tinyKey)]; 278 + 279 + if (!originalKey) continue; 280 + 281 + let originalValue: unknown; 282 + if (originalKey === "plants") { 283 + const plants: Record<string, unknown>[] = []; 284 + const plantStrings = (tinyValue ?? "").split("\uE003"); 285 + for (const plantString of plantStrings) { 286 + if (!plantString) continue; 287 + const plantObj: Record<string, unknown> = {}; 288 + const plantData = plantString.slice(1, -1).split("\uE002"); 289 + for (const plantPair of plantData) { 290 + const [plantTinyKey, plantValueStr] = plantPair.split("\uE004"); 291 + const plantOriginalKey = REVERSE_TINYIFIER_MAP[Number(plantTinyKey)]; 292 + if (!plantOriginalKey) continue; 293 + if (["zIndex", "plantRow", "plantCol"].includes(plantOriginalKey)) { 294 + plantObj[plantOriginalKey] = parseInt(plantValueStr, 10); 295 + } else { 296 + plantObj[plantOriginalKey] = plantValueStr; 297 + } 298 + } 299 + plants.push(plantObj); 300 + } 301 + originalValue = plants; 302 + } else if (originalKey === "lfValue") { 303 + originalValue = (tinyValue ?? "").split("\uE000").map((n) => Number(n)); 304 + } else if (["sun", "stripeCol"].includes(originalKey)) { 305 + originalValue = parseInt(tinyValue, 10); 306 + } else { 307 + originalValue = tinyValue; 308 + } 309 + 310 + originalClone[originalKey] = originalValue; 311 + } 312 + 313 + return originalClone as unknown as CloneLike; 314 + } 315 + 316 + function decodeIZL2Bytes(bytes: Uint8Array): CloneLike { 317 + const decompressed = pako.inflate(bytes); 318 + const decompressedString = new TextDecoder().decode(decompressed); 319 + return untinyifyClone_IZL2(decompressedString); 320 + } 321 + 322 + function decodeIZLBytes(bytes: Uint8Array): CloneLike { 323 + const decompressed = pako.inflate(bytes); 324 + const decompressedString = new TextDecoder().decode(decompressed); 325 + const data = decompressedString.split(";"); 326 + 327 + const levelData = JSON.parse(data[0]) as Record<string, unknown>; 328 + const screenshot = data[1]; 329 + if (screenshot) { 330 + levelData.screenshot = "data:image/webp;base64," + screenshot; 331 + } 332 + 333 + return levelData as unknown as CloneLike; 334 + } 335 + 336 + function base64ToBytes(base64: string): Uint8Array { 337 + const bin = globalThis.atob(base64); 338 + const out = new Uint8Array(bin.length); 339 + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); 340 + return out; 341 + } 342 + 343 + function stringToBytesIZL3(str: string): Uint8Array { 344 + const base64 = str[0] === "|" ? str.slice(1) : str; 345 + return base64ToBytes(base64); 346 + } 347 + 348 + function stringToBytesIZL2(str: string): Uint8Array { 349 + const base64 = str[0] === "=" ? str.slice(1) : str; 350 + return base64ToBytes(base64); 351 + } 352 + 353 + function stringToBytesIZL(str: string): Uint8Array { 354 + return base64ToBytes(str); 355 + } 356 + 357 + export function detectFileVersion(bytes: Uint8Array): number { 358 + if ( 359 + bytes.length >= 4 && 360 + bytes[0] === IZL3_HEADER[0] && 361 + bytes[1] === IZL3_HEADER[1] && 362 + bytes[2] === IZL3_HEADER[2] && 363 + bytes[3] === IZL3_HEADER[3] 364 + ) { 365 + return 3; 366 + } 367 + 368 + if (bytes.length >= 2 && bytes[0] === 0x78) { 369 + return 2; 370 + } 371 + 372 + return 1; 373 + } 374 + 375 + export function detectStringVersion(str: string): number { 376 + if (str[0] === "|") return 3; 377 + if (str[0] === "=") return 2; 378 + return 1; 379 + } 380 + 381 + export function decodeBytes(compressedData: Uint8Array | ArrayBuffer | number[]): CloneLike { 382 + const bytes = Array.isArray(compressedData) ? new Uint8Array(compressedData) : new Uint8Array(compressedData); 383 + const version = detectFileVersion(bytes); 384 + 385 + if (version === 3) { 386 + return decodeIZL3Bytes(bytes.slice(4)); // strip IZL3 header 387 + } 388 + if (version === 2) { 389 + return decodeIZL2Bytes(bytes); 390 + } 391 + return decodeIZLBytes(bytes); 392 + } 393 + 394 + export function decodeString(str: string): CloneLike { 395 + const version = detectStringVersion(str); 396 + if (version === 3) return decodeIZL3Bytes(stringToBytesIZL3(str)); 397 + if (version === 2) return decodeIZL2Bytes(stringToBytesIZL2(str)); 398 + return decodeIZLBytes(stringToBytesIZL(str)); 399 + } 400 + 401 + export function decodeFile(compressedData: Uint8Array | ArrayBuffer | number[]): Clone { 402 + return decodeBytes(compressedData) as Clone; 403 + }
modules/server/app_middleware.ts modules/app_middleware.ts
modules/server/auth.ts modules/auth.ts
modules/server/config.ts modules/config.ts
+12
modules/server/db.ts modules/db.ts
··· 11 11 consumeOneTimeTokenForLevel: (token: string, levelId: number) => boolean; 12 12 }; 13 13 14 + export type LevelRecord = { 15 + id: number; 16 + name: string; 17 + author: string; 18 + sun: number; 19 + is_water: number; 20 + difficulty: number; 21 + favorites: number; 22 + plays: number; 23 + version: number; 24 + }; 25 + 14 26 function tableHasColumn(db: Database, tableName: string, columnName: string) { 15 27 try { 16 28 const cols = db.prepare(`PRAGMA table_info(${tableName})`).all() as any[];
modules/server/discord.ts modules/discord.ts
-34
modules/server/levels_io.ts
··· 1 - import { decodeFile } from "../decode.ts"; 2 - 3 - export function detectVersion(fileBytes: Uint8Array): number { 4 - const izl3Header = new Uint8Array([0x49, 0x5a, 0x4c, 0x33]); 5 - const fileHeader = fileBytes.slice(0, 4); 6 - 7 - if ( 8 - fileHeader.length >= 4 && 9 - fileHeader[0] === izl3Header[0] && 10 - fileHeader[1] === izl3Header[1] && 11 - fileHeader[2] === izl3Header[2] && 12 - fileHeader[3] === izl3Header[3] 13 - ) { 14 - return 3; 15 - } 16 - 17 - return 0; 18 - } 19 - 20 - type DecodeLevelResult = { decoded: ReturnType<typeof decodeFile>; decodeError: null } | { decoded: null; decodeError: string }; 21 - 22 - export async function decodeLevelFromDisk(dataFolderPath: string, levelId: number, version: number): Promise<DecodeLevelResult> { 23 - try { 24 - const fileExtension = `izl${version || 3}`; 25 - const filePath = `${dataFolderPath}/${levelId}.${fileExtension}`; 26 - const fileBytes = await Deno.readFile(filePath); 27 - return { decoded: decodeFile(fileBytes), decodeError: null }; 28 - } catch (error) { 29 - return { 30 - decoded: null, 31 - decodeError: (error as Error).message || String(error), 32 - }; 33 - } 34 - }
modules/server/moderation.ts modules/moderation.ts
modules/server/public_folder.ts modules/public_folder.ts
modules/server/request.ts modules/request.ts
+14 -7
modules/server/routes/admin.ts modules/routes/admin.ts
··· 1 - import type { DbContext } from "../db.ts"; 1 + import type { DbContext, LevelRecord } from "../db.ts"; 2 + import { decodeLevelFromDisk, encodeIZL3FileToDisk } from "../levels_io.ts"; 2 3 3 4 export function registerAdminRoutes( 4 5 app: any, ··· 83 84 }); 84 85 85 86 // update a level (admin only) 86 - app.put("/api/admin/levels/:id", deps.ensureAuthenticatedOrConsumeTokenForLevelParam, (req: any, res: any) => { 87 + app.put("/api/admin/levels/:id", deps.ensureAuthenticatedOrConsumeTokenForLevelParam, async (req: any, res: any) => { 87 88 try { 88 89 const levelId = parseInt(req.params.id); 89 90 ··· 91 92 return res.status(400).json({ error: "Invalid level ID" }); 92 93 } 93 94 94 - const existingLevel = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId); 95 + const existingLevel = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId) as LevelRecord | undefined; 95 96 96 97 if (!existingLevel) { 97 98 return res.status(404).json({ error: "Level not found" }); ··· 116 117 117 118 const updatedLevel = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId); 118 119 120 + // now update the level file on disk if name or sun changed 121 + if (name !== existingLevel.name || sun !== existingLevel.sun) { 122 + const levelData = await decodeLevelFromDisk(dbCtx.dataFolderPath, levelId, existingLevel.version); 123 + if (levelData.decoded) { 124 + levelData.decoded.name = name; 125 + levelData.decoded.sun = sun; 126 + 127 + encodeIZL3FileToDisk(dbCtx.dataFolderPath, levelId, levelData.decoded); 128 + } 129 + } 119 130 res.json({ 120 131 success: true, 121 132 level: updatedLevel, ··· 144 155 return res.status(404).json({ error: "Level not found" }); 145 156 } 146 157 147 - type LevelRecord = { 148 - id: number; 149 - version: number; 150 - }; 151 158 const typedLevel = existingLevel as LevelRecord; 152 159 153 160 dbCtx.db.prepare("DELETE FROM favorites WHERE level_id = ?").run(levelId);
modules/server/routes/config.ts modules/routes/config.ts
+10 -24
modules/server/routes/levels.ts modules/routes/levels.ts
··· 1 1 import { Buffer } from "node:buffer"; 2 2 import { EmbedBuilder, type WebhookClient } from "discord.js"; 3 3 4 - import { allPlantsStringArray, decodeFile } from "../../decode.ts"; 5 - import { validateClone } from "../../validate.ts"; 4 + import { allPlantsStringArray, decodeFile, decodeLevelFromDisk, detectFileVersion } from "../levels_io.ts"; 5 + import { validateClone } from "../validate.ts"; 6 6 7 7 import type { ServerConfig } from "../config.ts"; 8 - import type { DbContext } from "../db.ts"; 8 + import type { DbContext, LevelRecord } from "../db.ts"; 9 9 import { trySendDiscordWebhook } from "../discord.ts"; 10 - import { decodeLevelFromDisk, detectVersion } from "../levels_io.ts"; 11 10 import type { ModerationResult } from "../moderation.ts"; 12 11 import type { TurnstileResponse } from "../turnstile.ts"; 13 12 import { getClientIP } from "../request.ts"; ··· 93 92 }); 94 93 } 95 94 96 - author = req.query.author as string; 95 + author = (req.query.author as string).slice(0, 11); 97 96 turnstileResponse = req.query.turnstileResponse as string; 98 97 levelBinary = req.body; 99 98 ··· 122 121 } 123 122 } 124 123 125 - const version = detectVersion(levelBinary); 124 + const version = detectFileVersion(levelBinary); 126 125 if (version !== 3) { 127 126 return res.status(400).json({ 128 127 error: "Invalid level data format", ··· 134 133 let cloneData; 135 134 try { 136 135 cloneData = decodeFile(levelBinary); 136 + const [isValid, errorMessage] = validateClone(cloneData); 137 137 138 - if (!validateClone(cloneData)) { 138 + if (!isValid) { 139 139 return res.status(400).json({ 140 140 error: "Invalid level data", 141 - message: "The level data failed validation checks", 141 + message: "The level data failed validation checks: " + errorMessage, 142 142 }); 143 143 } 144 144 ··· 190 190 const levelId = queryResult ? (queryResult as { id: number }).id : 0; 191 191 192 192 // store the level binary data 193 - const levelFilename = `${levelId}.izl${version}`; 193 + // @ts-expect-error -- 3 is the only valid version right now but this will change in future 194 + const levelFilename = `${levelId}.izl${version === 1 ? "" : version}`; 194 195 const levelPath = `${dbCtx.dataFolderPath}/${levelFilename}`; 195 196 await Deno.writeFile(levelPath, levelBinary); 196 197 ··· 478 479 479 480 dbCtx.db.prepare("UPDATE levels SET plays = plays + 1 WHERE id = ?").run(levelId); 480 481 481 - type LevelRecord = { 482 - id: number; 483 - name: string; 484 - author: string; 485 - version: number; 486 - is_water: number; 487 - }; 488 - 489 482 const typedLevel = level as LevelRecord; 490 483 491 484 const fileExtension = `izl${typedLevel.version || 3}`; ··· 526 519 if (!level) { 527 520 return res.status(404).json({ error: "Level not found" }); 528 521 } 529 - 530 - type LevelRecord = { 531 - id: number; 532 - name: string; 533 - author: string; 534 - version?: number | null; 535 - }; 536 522 537 523 const typedLevel = level as LevelRecord; 538 524
modules/server/routes/root.ts modules/routes/root.ts
modules/server/turnstile.ts modules/turnstile.ts
+15 -15
modules/validate.ts
··· 1 - import type { decodeFile } from "./decode.ts"; 1 + import type { decodeFile } from "./levels_io.ts"; 2 2 3 3 type Clone = ReturnType<typeof decodeFile>; 4 4 ··· 191 191 return true; 192 192 } 193 193 194 - export function validateClone(clone: Clone): boolean { 194 + export function validateClone(clone: Clone): [boolean, string?] { 195 195 const [doesCloneHaveAllRequiredFields, missingFields] = cloneHasAllRequiredFields(clone); 196 196 if (!doesCloneHaveAllRequiredFields) { 197 197 console.error("Clone is missing required fields:", missingFields); 198 - return false; 198 + return [false, `Clone is missing required fields: ${missingFields}`]; 199 199 } 200 200 201 201 if (!plantsHasAllRequiredFields(clone)) { 202 202 console.error("Plants are missing required fields."); 203 - return false; 203 + return [false, "Plants are missing required fields."]; 204 204 } 205 205 206 206 if (!max3PlantsIn1Tile(clone)) { 207 207 console.error("Too many plants in one tile."); 208 - return false; 208 + return [false, "Too many plants in one tile."]; 209 209 } 210 210 211 211 if (!maxLfLength7(clone)) { 212 212 console.error("LF value length exceeds 7."); 213 - return false; 213 + return [false, "LF value length exceeds 7."]; 214 214 } 215 215 216 216 if (!maxSun9990(clone)) { 217 217 console.error("Sun value exceeds 9990."); 218 - return false; 218 + return [false, "Sun value exceeds 9990."]; 219 219 } 220 220 221 221 if (!validMusic(clone)) { 222 222 console.error("Invalid music track."); 223 - return false; 223 + return [false, "Invalid music track."]; 224 224 } 225 225 226 226 if (!validStripeCol(clone)) { 227 227 console.error("Invalid stripe column."); 228 - return false; 228 + return [false, "Invalid stripe column."]; 229 229 } 230 230 231 231 if (!hasValidPlantCount(clone)) { 232 232 console.error("Too many plants in the clone."); 233 - return false; 233 + return [false, "Too many plants in the clone."]; 234 234 } 235 235 236 236 if (!noPlantsAfterStripe(clone)) { 237 237 console.error("Plants are placed after the stripe column."); 238 - return false; 238 + return [false, "Plants are placed after the stripe column."]; 239 239 } 240 240 241 241 if (!noDuplicatePlantTypesInTile(clone)) { 242 242 console.error("Duplicate plant types in the same tile."); 243 - return false; 243 + return [false, "Duplicate plant types in the same tile."]; 244 244 } 245 245 246 246 if (!noInvalidPlants(clone)) { 247 247 console.error("Clone contains invalid plants."); 248 - return false; 248 + return [false, "Clone contains invalid plants."]; 249 249 } 250 250 251 251 if (!hasAllRequiredProperties(clone)) { 252 252 console.error("Clone is missing some required properties."); 253 - return false; 253 + return [false, "Clone is missing some required properties."]; 254 254 } 255 255 256 - return true; 256 + return [true]; 257 257 }
+40 -34
public/admin.html
··· 190 190 191 191 <div> 192 192 <label> 193 - <input type="checkbox" id="editIsWater" role="switch" /> 193 + <input type="checkbox" id="editIsWater" role="switch" disabled /> 194 194 Water Level 195 195 </label> 196 196 </div> ··· 253 253 return true; 254 254 } 255 255 256 + function showAuthedUI(user) { 257 + document.getElementById("login-container").classList.add("hidden"); 258 + document.getElementById("user-profile").classList.remove("hidden"); 259 + document.getElementById("admin-content").classList.remove("hidden"); 260 + 261 + if (user) { 262 + document.getElementById("user-name").textContent = user.displayName || user.username || "Admin"; 263 + if (user.avatarUrl) { 264 + document.getElementById("user-avatar").src = user.avatarUrl; 265 + } 266 + } 267 + } 268 + 269 + function showLoginUI() { 270 + document.getElementById("login-container").classList.remove("hidden"); 271 + document.getElementById("user-profile").classList.add("hidden"); 272 + // token-flow should still be able to open the requested modal. 273 + if (hasTokenFlowParams) { 274 + document.getElementById("admin-content").classList.remove("hidden"); 275 + } else { 276 + document.getElementById("admin-content").classList.add("hidden"); 277 + } 278 + } 279 + 256 280 async function validateTokenFlow() { 257 281 if (!hasTokenFlowParams) return false; 258 282 try { ··· 283 307 // check authentication status 284 308 try { 285 309 const authResponse = await fetch("/api/auth/status"); 286 - const authData = await authResponse.json(); 287 - 288 - if (authData.authenticated) { 289 - // user is authenticated, show admin content 290 - document.getElementById("login-container").classList.add("hidden"); 291 - document.getElementById("user-profile").classList.remove("hidden"); 292 - document.getElementById("admin-content").classList.remove("hidden"); 293 - 294 - // update user profile 295 - document.getElementById("user-name").textContent = authData.user.displayName; 296 - if (authData.user.avatarUrl) { 297 - document.getElementById("user-avatar").src = authData.user.avatarUrl; 298 - } 310 + // if github auth is disabled server-side, this route won't exist. 311 + if (authResponse.status === 404) { 312 + showAuthedUI({ displayName: "Auth disabled" }); 299 313 } else { 300 - // user is not authenticated, show login button 301 - document.getElementById("login-container").classList.remove("hidden"); 302 - document.getElementById("user-profile").classList.add("hidden"); 303 - // token-flow should still be able to open the requested modal. 304 - if (hasTokenFlowParams) { 305 - document.getElementById("admin-content").classList.remove("hidden"); 314 + const authData = await authResponse.json(); 315 + if (authData.authenticated) { 316 + showAuthedUI(authData.user); 306 317 } else { 307 - document.getElementById("admin-content").classList.add("hidden"); 318 + showLoginUI(); 308 319 } 309 320 } 310 321 } catch (error) { 311 322 console.error("Error checking authentication:", error); 312 - // fallback to showing login 313 - document.getElementById("login-container").classList.remove("hidden"); 314 - document.getElementById("user-profile").classList.add("hidden"); 315 - if (hasTokenFlowParams) { 316 - document.getElementById("admin-content").classList.remove("hidden"); 317 - } else { 318 - document.getElementById("admin-content").classList.add("hidden"); 319 - } 323 + // fallback to showing login (if server is down, etc.) 324 + showLoginUI(); 320 325 } 321 326 322 327 const API_BASE_URL = "/api"; ··· 427 432 // check if user is authenticated before fetching 428 433 try { 429 434 const authResponse = await fetch("/api/auth/status"); 430 - const authData = await authResponse.json(); 431 - 432 - if (!authData.authenticated) { 433 - console.log("User not authenticated, skipping level fetch"); 434 - return; 435 + if (authResponse.status !== 404) { 436 + const authData = await authResponse.json(); 437 + if (!authData.authenticated) { 438 + console.log("User not authenticated, skipping level fetch"); 439 + return; 440 + } 435 441 } 436 442 } catch (error) { 437 443 console.error("Error checking authentication:", error);