diff --git a/.cursorindexingignore b/.cursorindexingignore new file mode 100644 index 00000000..953908e7 --- /dev/null +++ b/.cursorindexingignore @@ -0,0 +1,3 @@ + +# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references +.specstory/** diff --git a/.gitignore b/.gitignore index 6fb8d4d6..7cddf5d6 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ yarn-debug.log* yarn-error.log* # local env files +.env .env.local .env.development.local .env.test.local @@ -50,3 +51,4 @@ package-lock.json /Bruno /tsconfig.tsbuildinfo /public/generated.css +/.specstory \ No newline at end of file diff --git a/bun.lock b/bun.lock index 56e3e6d0..1fe12d4e 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@elysiajs/jwt": "^1.4.0", "@elysiajs/static": "^1.4.6", "@kitajs/html": "^4.2.11", + "adm-zip": "^0.5.16", "elysia": "^1.4.16", "sanitize-filename": "^1.6.3", "tar": "^7.5.2", @@ -18,6 +19,7 @@ "@kitajs/ts-html-plugin": "^4.1.3", "@tailwindcss/cli": "^4.1.17", "@tailwindcss/postcss": "^4.1.17", + "@types/adm-zip": "^0.5.7", "@types/bun": "latest", "@types/node": "^24.10.1", "@typescript-eslint/parser": "^8.46.4", @@ -219,6 +221,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/adm-zip": ["@types/adm-zip@0.5.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw=="], + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -255,6 +259,8 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="], + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], diff --git a/package.json b/package.json index 6aea0c44..29d0efc4 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@elysiajs/jwt": "^1.4.0", "@elysiajs/static": "^1.4.6", "@kitajs/html": "^4.2.11", + "adm-zip": "^0.5.16", "elysia": "^1.4.16", "sanitize-filename": "^1.6.3", "tar": "^7.5.2" @@ -34,6 +35,7 @@ "@kitajs/ts-html-plugin": "^4.1.3", "@tailwindcss/cli": "^4.1.17", "@tailwindcss/postcss": "^4.1.17", + "@types/adm-zip": "^0.5.7", "@types/bun": "latest", "@types/node": "^24.10.1", "@typescript-eslint/parser": "^8.46.4", diff --git a/src/converters/main.ts b/src/converters/main.ts index cee3a302..6aeff805 100644 --- a/src/converters/main.ts +++ b/src/converters/main.ts @@ -1,4 +1,8 @@ import { Cookie } from "elysia"; +import AdmZip from "adm-zip"; +import { unlink, rename, stat } from "node:fs/promises"; +import path from "node:path"; +import sanitize from "sanitize-filename"; import db from "../db/db"; import { MAX_CONVERT_PROCESS } from "../helpers/env"; import { normalizeFiletype, normalizeOutputFiletype } from "../helpers/normalizeFiletype"; @@ -148,6 +152,129 @@ function chunks(arr: T[], size: number): T[][] { ); } +async function handleMultiFrameOutput( + targetPath: string, + newFileName: string, + userOutputDir: string, +): Promise { + const targetFile = Bun.file(targetPath); + const exists = await targetFile.exists(); + + if (exists) { + // Target file exists as expected, return unchanged + return newFileName; + } + + // Target file doesn't exist, multi-frame detection will be needed + console.log(`Multi-frame detection needed for: ${newFileName}`); + console.log(`Expected file not found: ${targetPath}`); + + // Extract base filename and extension + const lastDotIndex = newFileName.lastIndexOf("."); + const baseFileName = lastDotIndex > 0 ? newFileName.substring(0, lastDotIndex) : newFileName; + const extension = lastDotIndex > 0 ? newFileName.substring(lastDotIndex + 1) : ""; + + // Sanitize: strip path separators then apply sanitize-filename to prevent path traversal + const safeBaseFileName = sanitize(baseFileName.replace(/[/\\]/g, "")); + + console.log(`Base filename: ${safeBaseFileName}, Extension: ${extension}`); + + // Escape glob metacharacters in baseFileName to prevent pattern injection + const escapedBaseFileName = safeBaseFileName.replace(/[*?[\]{}!\\]/g, "\\$&"); + + // Search for frame files matching pattern: baseFileName-*.extension + const framePattern = `${escapedBaseFileName}-*.${extension}`; + const glob = new Bun.Glob(framePattern); + const frameFiles: string[] = []; + + // Scan the output directory for matching files + for await (const file of glob.scan({ cwd: userOutputDir, onlyFiles: true })) { + frameFiles.push(file); + } + + console.log(`Detected ${frameFiles.length} frame file(s):`); + for (const frameFile of frameFiles) { + console.log(` - ${frameFile}`); + } + + // Handle based on number of frame files detected + if (frameFiles.length === 0) { + throw new Error(`No output files generated for ${newFileName}`); + } + + if (frameFiles.length === 1) { + // Single frame: rename to expected target path + const frame = frameFiles[0]!; + const singleFramePath = path.join(userOutputDir, frame); + console.log(`Renaming single frame: ${frameFiles[0]} -> ${newFileName}`); + await rename(singleFramePath, targetPath); + return newFileName; + } + + // Multiple frames: create a zip archive + console.log(`Creating zip archive for ${frameFiles.length} frames`); + const zipFileName = `${safeBaseFileName}.zip`; + const zipPath = path.join(userOutputDir, zipFileName); + + // Verify the resolved zip path is inside userOutputDir to prevent path traversal + const resolvedZipPath = path.resolve(zipPath); + const resolvedOutputDir = path.resolve(userOutputDir); + if (!resolvedZipPath.startsWith(resolvedOutputDir + path.sep)) { + throw new Error(`Path traversal detected: zip path escapes output directory`); + } + + // Memory safeguard: reject before loading anything if total frame size exceeds 200 MB + const MAX_ZIP_BYTES = 200 * 1024 * 1024; + let totalFrameSize = 0; + for (const frameFile of frameFiles) { + const { size } = await stat(path.join(userOutputDir, frameFile)); + totalFrameSize += size; + } + if (totalFrameSize > MAX_ZIP_BYTES) { + throw new Error( + `Total frame size (${Math.round(totalFrameSize / 1024 / 1024)} MB) exceeds the 200 MB zip memory limit`, + ); + } + + const zip = new AdmZip(); + + // Add all frame files to the zip + for (const frameFile of frameFiles) { + const frameFilePath = path.join(userOutputDir, frameFile); + console.log(`Adding to zip: ${frameFile}`); + zip.addLocalFile(frameFilePath); + } + + try { + // Write synchronously — no callback, no hanging Promise + zip.writeZip(zipPath); + + console.log(`Zip created successfully: ${zipFileName}`); + + // Delete individual frame files only after zip is confirmed written + for (const frameFile of frameFiles) { + const frameFilePath = path.join(userOutputDir, frameFile); + try { + await unlink(frameFilePath); + console.log(`Deleted frame file: ${frameFile}`); + } catch (err) { + console.error(`Failed to delete frame file ${frameFile}: ${err instanceof Error ? err.message : String(err)}`); + } + } + } catch (err) { + // Clean up any partial zip, but leave frame files intact + const partialExists = await Bun.file(zipPath).exists(); + if (partialExists) { + await unlink(zipPath).catch(() => {}); + } + throw new Error( + `Failed to create zip for ${safeBaseFileName}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + return zipFileName; +} + export async function handleConvert( fileNames: string[], userUploadsDir: string, @@ -175,9 +302,10 @@ export async function handleConvert( toProcess.push( new Promise((resolve, reject) => { mainConverter(filePath, fileType, convertTo, targetPath, {}, converterName) - .then((r) => { + .then(async (r) => { + const finalFileName = await handleMultiFrameOutput(targetPath, newFileName, userOutputDir); if (jobId.value) { - query.run(jobId.value, fileName, newFileName, r); + query.run(jobId.value, fileName, finalFileName, r); } resolve(r); })