Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 0 additions & 17 deletions exe.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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`;

Expand Down Expand Up @@ -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);
Expand Down
29 changes: 12 additions & 17 deletions server/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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)) {
Expand All @@ -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 });

Expand Down Expand Up @@ -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);
Expand Down
61 changes: 28 additions & 33 deletions server/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
Expand All @@ -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<T>(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("└────────────────────────────────┘");
Expand Down
31 changes: 23 additions & 8 deletions server/persistence.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(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) {
Expand All @@ -15,16 +31,15 @@ export const readJsonFile = <T>(filePath: string, defaultValue: T): T => {
};

export const writeJsonFile = <T>(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");
Loading