diff --git a/exe.ts b/exe.ts index af520ee..9221c9f 100644 --- a/exe.ts +++ b/exe.ts @@ -1,4 +1,3 @@ -import { cp, rm } from "node:fs/promises"; import { readdirSync, statSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { zipSync } from "fflate"; @@ -27,21 +26,6 @@ function getFilesForFflate(dir: string, baseDir = "") { return filesObj; } -async function copyFrontend(serverDir: string) { - const sourceDir = "./dist/frontend" as const; - const destDir = `${serverDir}/frontend`; - - try { - console.log("─".repeat(Math.min(130, process.stdout.columns))); - console.log("⌛ Copying frontend folder..."); - await rm(destDir, { recursive: true, force: true }); - await cp(sourceDir, destDir, { recursive: true, force: true }); - console.log("✅ Successfully copied frontend folder."); - } catch (err) { - console.error("❌ Failed to copy frontend folder:", err); - } -} - function packOutputs(serverDir: string) { const zipDest = `./dist/projection-v${version.version}.zip`; @@ -214,7 +198,6 @@ if (result.success) { const serverDir = "./dist/server" as const; - await copyFrontend(serverDir); if (!values["no-release"]) packOutputs(serverDir); } else { console.error("❌ Failed to build server:", result.logs); diff --git a/server/import.ts b/server/import.ts index a7aca7a..ab270ed 100644 --- a/server/import.ts +++ b/server/import.ts @@ -3,11 +3,9 @@ import { existsSync, mkdirSync, rmSync } from "fs"; import { basename, dirname, extname, join } from "path"; import { JSDOM } from "jsdom"; import DOMPurify from "dompurify"; +import { publicDir } from "$/persistence"; -const TEMP_ASSETS_DIR = join( - process.env.NODE_ENV === "production" ? dirname(process.execPath) : process.cwd(), - "public/__temp/assets", -); +const TEMP_ASSETS_DIR = publicDir("assets"); // Allowed file extensions for projections const ALLOWED_EXTENSIONS = [".mp4", ".webm", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"]; @@ -40,12 +38,17 @@ function validateSVG(svgString: string): { isSafe: boolean; sanitized: string } } export async function importRequest(req: Request, path: string) { - if (req.method === "POST" || req.method === "PUT") { - // Path Traversal Protection: Extract ONLY the file name, discarding any folder paths - const rawPath = decodeURIComponent(path); - const filename = basename(rawPath); - if (!filename) return new Response("Bad Request: Invalid filename", { status: 400 }); + // Path Traversal Protection: Extract ONLY the file name, discarding any folder paths + const filename = basename(path); + if (!filename) return new Response("Bad Request: Invalid filename", { status: 400 }); + + const filePath = join(TEMP_ASSETS_DIR, filename); + if (!filePath.startsWith(TEMP_ASSETS_DIR)) { + // Path Traversal Protection: Prevent path traversal attacks + return new Response("Not Found: Invalid filename", { status: 404 }); + } + if (req.method === "POST" || req.method === "PUT") { // Extension Validation: Prevent executable/script uploads const ext = extname(filename).toLowerCase(); if (!ALLOWED_EXTENSIONS.includes(ext)) { @@ -67,7 +70,6 @@ export async function importRequest(req: Request, path: string) { return new Response("Payload Too Large", { status: 413 }); } - const filePath = join(TEMP_ASSETS_DIR, filename); const dirPath = dirname(filePath); if (!existsSync(dirPath)) mkdirSync(dirPath, { recursive: true }); @@ -98,13 +100,6 @@ export async function importRequest(req: Request, path: string) { return new Response("Internal Server Error", { status: 500 }); } } else if (req.method === "GET") { - // Apply basename check on GET requests too so users can't read internal server files! - const rawPath = decodeURIComponent(path); - const filename = basename(rawPath); - - if (!filename) return new Response("Bad Request", { status: 400 }); - - const filePath = join(TEMP_ASSETS_DIR, filename); const reqFile = file(filePath); if (await reqFile.exists()) return new Response(reqFile); diff --git a/server/index.ts b/server/index.ts index a2d8177..d5af6ba 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,11 +1,11 @@ -import { serve, file } from "bun"; -import { dirname, join } from "path"; +import { serve } from "bun"; import open from "open"; import { engine, SERVER_PORT, FRONTEND_PORT } from "$/socket"; import { cleanupAssets, importRequest, MAX_FILE_SIZE } from "$/import"; +import index from "../dist/frontend/index.html"; +import { execDir } from "$/persistence"; const isProd = process.env.NODE_ENV === "production"; -const FRONTEND_DIST = join(dirname(process.execPath), "frontend"); const { fetch, ...socketEngineHandler } = engine.handler(); declare const VERSION: string; @@ -29,9 +29,22 @@ serve({ // from the libraries being used. port: SERVER_PORT, maxRequestBodySize: MAX_FILE_SIZE, + routes: { + "/": prod(index), + "/index.html": prod(index), + "/view": prod(index), + }, async fetch(req, server) { const url = new URL(req.url); - const path = url.pathname; + const path = decodeURIComponent(url.pathname) + .replace(/^(\.\.(\/|\\|$))+/g, "") + .replace(/\\/g, "/"); + + const filePath = execDir(path); + if (!filePath.startsWith(execDir())) { + // Path Traversal Protection: Prevent path traversal attacks + return new Response("Not Found: Invalid filename", { status: 404 }); + } if (path.startsWith(engine.opts.path)) { return engine.handleRequest(req, server); @@ -41,38 +54,20 @@ serve({ return await importRequest(req, path.replace("/api/assets/", "")); } - if (!isProd) { - return new Response( - "Bun Backend: Running in DEV mode. Please use the Vite dev server to view the frontend.", - { headers: { "Content-Type": "text/plain" } }, - ); - } - - let reqPath = decodeURIComponent(path); - if (reqPath === "/") reqPath = "/index.html"; - - const targetPath = join(FRONTEND_DIST, reqPath); - if (!targetPath.startsWith(FRONTEND_DIST)) { - return new Response("Forbidden: Invalid Path", { status: 403 }); - } - - let requestedFile = file(targetPath); - if (!(await requestedFile.exists())) { - requestedFile = file(join(FRONTEND_DIST, "index.html")); - - // Safety check: if index.html is completely missing, return a clean 404 - if (!(await requestedFile.exists())) { - return new Response( - "Frontend files not found. Ensure the 'frontend' folder is located in the same directory as this executable.", - { status: 404, headers: { "Content-Type": "text/plain" } }, - ); - } - } - - return new Response(requestedFile); + return new Response("Not Found: Invalid Path", { status: 404 }); }, }); +function prod(val: T) { + if (!isProd) { + return new Response( + "Bun Backend: Running in DEV mode. Please use the Vite dev server to view the frontend.", + ); + } + + return val; +} + console.log(`│ Server: http://localhost:${SERVER_PORT} │`); console.log(`│ Mode : ${isProd ? "production " : "development"} │`); console.log("└────────────────────────────────┘"); diff --git a/server/persistence.ts b/server/persistence.ts index 2b721e9..fe7be13 100644 --- a/server/persistence.ts +++ b/server/persistence.ts @@ -1,11 +1,27 @@ import fs from "fs"; -import path from "path"; +import path, { dirname, join } from "path"; + +let EXEC_DIR = ""; +let PUBLIC_BASE = ""; +const isProd = process.env.NODE_ENV === "production"; + +export function execDir(...segments: string[]) { + EXEC_DIR ||= isProd ? dirname(process.execPath) : process.cwd(); + return join(EXEC_DIR, ...segments); +} + +export function publicDir(...segments: string[]) { + if (!PUBLIC_BASE) { + const publicPath = isProd ? ["public"] : ["public", "__temp"]; + PUBLIC_BASE = execDir(...publicPath); + } + return join(PUBLIC_BASE, ...segments); +} export const readJsonFile = (filePath: string, defaultValue: T): T => { - const fp = path.join(process.cwd(), filePath); try { - if (fs.existsSync(fp)) { - const fileContent = fs.readFileSync(fp, "utf-8"); + if (fs.existsSync(filePath)) { + const fileContent = fs.readFileSync(filePath, "utf-8"); return JSON.parse(fileContent) as T; } } catch (error) { @@ -15,16 +31,15 @@ export const readJsonFile = (filePath: string, defaultValue: T): T => { }; export const writeJsonFile = (filePath: string, value: T) => { - const fp = path.join(process.cwd(), filePath); - const dirPath = path.dirname(fp); + const dirPath = path.dirname(filePath); try { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } - fs.writeFileSync(fp, JSON.stringify(value, null, 2)); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2)); } catch (error) { console.error("Error writing to settings file:", error); } }; -export const settingsFP = "public/__temp/settings.json"; +export const settingsFP = publicDir("settings.json");