import http from "http"; import {createReadStream, createWriteStream, existsSync} from "fs"; import {promises as fs} from "fs"; import path from "path"; import {fileURLToPath} from "url"; import crypto from "crypto"; import {pipeline} from "stream/promises"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const DATA_DIR = path.join(__dirname, "data", "projects"); const UPLOADS_DIR = path.join(__dirname, "data", "uploads"); const INDEX_FILE = path.join(DATA_DIR, "index.json"); const FRONTEND_DIST = path.join(__dirname, "..", "frontend", "dist"); const FRONTEND_ROOT = path.join(__dirname, "..", "frontend"); const PORT = parseInt(process.env.PORT, 10) || 8080; const MIME_TYPES = { ".html": "text/html", ".js": "text/javascript", ".mjs": "text/javascript", ".css": "text/css", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".ico": "image/x-icon", ".glb": "model/gltf-binary", ".gltf": "model/gltf+json", ".xkt": "application/octet-stream", ".las": "application/octet-stream", ".laz": "application/octet-stream", ".bim": "application/octet-stream", ".step": "application/octet-stream", ".stp": "application/octet-stream", ".wasm": "application/wasm", ".txt": "text/plain" }; const CONVERTIBLE_MODEL_FORMATS = new Map([ [".step", "step"], [".stp", "step"] ]); const nowIso = () => new Date().toISOString(); const ensureStorage = async () => { await fs.mkdir(DATA_DIR, {recursive: true}); await fs.mkdir(UPLOADS_DIR, {recursive: true}); try { await fs.access(INDEX_FILE); } catch (err) { await fs.writeFile(INDEX_FILE, "[]", "utf8"); } }; const readIndex = async () => { try { const raw = await fs.readFile(INDEX_FILE, "utf8"); const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch (err) { console.error("[server] failed to read index.json", err); return []; } }; const writeIndex = async (items) => { await fs.writeFile(INDEX_FILE, JSON.stringify(items, null, 2), "utf8"); }; const sendJSON = (res, status, payload) => { setCors(res); res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(payload)); }; const sendText = (res, status, message) => { setCors(res); res.statusCode = status; res.setHeader("Content-Type", "text/plain"); res.end(message); }; const setCors = (res) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); }; const parseJSONBody = async (req) => { const chunks = []; for await (const chunk of req) { chunks.push(chunk); } if (!chunks.length) { return {}; } const raw = Buffer.concat(chunks).toString("utf8"); if (!raw.trim()) { return {}; } try { return JSON.parse(raw); } catch (err) { throw new Error("Invalid JSON"); } }; const safeProjectId = (id) => { if (typeof id !== "string") { return null; } const trimmed = id.trim(); if (!/^[-_a-zA-Z0-9]+$/.test(trimmed)) { return null; } return trimmed; }; const STATIC_ROOTS = [ {prefix: "/uploads/", dir: UPLOADS_DIR}, {prefix: "/data/", dir: path.join(__dirname, "data")}, {prefix: "/", dir: FRONTEND_DIST}, {prefix: "/", dir: FRONTEND_ROOT} ]; const resolveStaticPath = async (pathname) => { for (const {prefix, dir} of STATIC_ROOTS) { if (!pathname.startsWith(prefix)) { continue; } const rel = pathname.slice(prefix.length); const resolved = path.resolve(dir, rel); if (!resolved.startsWith(dir)) { continue; } try { const stats = await fs.stat(resolved); if (stats.isFile()) { return resolved; } } catch (_) { // keep searching } } return null; }; const serveStatic = async (req, res, url) => { const pathname = url.pathname || "/"; const targetPath = await resolveStaticPath(pathname); if (!targetPath) { const shouldFallback = pathname === "/" || !path.extname(pathname); if (shouldFallback) { const fallbackIndex = await resolveStaticPath("/index.html"); if (fallbackIndex) { return serveStatic(req, res, new URL("/index.html", url)); } } sendText(res, 404, "Not Found"); return; } const ext = path.extname(targetPath).toLowerCase(); const mime = MIME_TYPES[ext] || "application/octet-stream"; res.statusCode = 200; res.setHeader("Content-Type", mime); createReadStream(targetPath).pipe(res).on("error", (err) => { console.error("[server] failed to stream file", err); if (!res.headersSent) { sendText(res, 500, "Failed to read file"); } else { res.destroy(); } }); }; const decodeBase64 = (b64) => { try { return Buffer.from(b64, "base64"); } catch (err) { return null; } }; const sanitizeFilename = (name, fallback) => { if (!name || typeof name !== "string") { return fallback; } const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_"); return safe || fallback; }; const manifestPathForSource = (sourcePath) => path.join(path.dirname(sourcePath), `${path.basename(sourcePath)}.beam.json`); const srcFromUploadPath = (uploadPath) => { const relative = path.relative(UPLOADS_DIR, uploadPath).replace(/\\/g, "/"); return path.join("uploads", relative).replace(/\\/g, "/"); }; const resolveUploadSrc = (src) => { if (!src || typeof src !== "string") { return null; } let value = src.trim(); if (!value) { return null; } try { if (/^https?:\/\//i.test(value)) { const parsed = new URL(value); value = parsed.pathname; } } catch (_) { return null; } value = value.replace(/^\/+/, ""); if (!value.startsWith("uploads/")) { return null; } const relative = value.slice("uploads/".length); const resolved = path.resolve(UPLOADS_DIR, relative); if (!resolved.startsWith(UPLOADS_DIR)) { return null; } return resolved; }; const readJSONFile = async (filePath) => { try { const raw = await fs.readFile(filePath, "utf8"); return JSON.parse(raw); } catch (_) { return null; } }; const writeJSONFile = async (filePath, payload) => { await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8"); }; const processUploads = async (project) => { if (!Array.isArray(project.models)) { return; } const projectDir = path.join(UPLOADS_DIR, project.id); await fs.mkdir(projectDir, {recursive: true}); for (let i = 0; i < project.models.length; i++) { const model = project.models[i]; if (!model || !model.srcData) { continue; } const data = decodeBase64(model.srcData); if (!data) { throw new Error("Invalid srcData for model upload"); } const ext = path.extname(model.srcFilename || ""); const baseName = sanitizeFilename(model.srcFilename || `${model.id || "model"}${ext || ".bin"}`, `${model.id || "model"}.bin`); const filePath = path.join(projectDir, baseName); await fs.writeFile(filePath, data); model.src = path.join("uploads", project.id, baseName).replace(/\\/g, "/"); delete model.srcData; delete model.srcFilename; delete model.srcMime; } }; const handleRawUpload = async (req, res, url) => { const filenameParam = url.searchParams.get("filename"); const filenameHeader = req.headers["x-filename"]; const rawName = filenameParam || (Array.isArray(filenameHeader) ? filenameHeader[0] : filenameHeader) || "upload.bin"; const safeName = sanitizeFilename(rawName, "upload.bin"); // if client passes projectId in query, group uploads per project const projectId = safeProjectId(url.searchParams.get("projectId")); const uploadId = projectId || (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)); const dir = path.join(UPLOADS_DIR, uploadId); await fs.mkdir(dir, {recursive: true}); const targetPath = path.join(dir, safeName); const writeStream = createWriteStream(targetPath); try { await pipeline(req, writeStream); } catch (err) { console.error("[server] upload failed", err); sendText(res, 500, "Upload failed"); return; } const src = path.join("uploads", uploadId, safeName).replace(/\\/g, "/"); const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(safeName).toLowerCase()); if (sourceFormat) { const conversion = { componentTreeRequired: true, message: "Original STEP uploaded. XKT preview/component tree is waiting for NodeDcBimConverter.", sourceFormat, sourceSrc: src, status: "conversion_required", targetFormat: "xkt", updatedAt: nowIso() }; await writeJSONFile(manifestPathForSource(targetPath), conversion); sendJSON(res, 201, { src, conversion }); return; } sendJSON(res, 201, {src}); }; const handleConversionStatus = async (res, searchParams) => { const sourcePath = resolveUploadSrc(searchParams.get("src")); if (!sourcePath) { sendText(res, 400, "Invalid source path"); return; } if (!existsSync(sourcePath)) { sendText(res, 404, "Source file not found"); return; } const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(sourcePath).toLowerCase()); if (!sourceFormat) { sendJSON(res, 200, { sourceSrc: srcFromUploadPath(sourcePath), status: "ready" }); return; } const manifest = await readJSONFile(manifestPathForSource(sourcePath)); if (!manifest) { sendJSON(res, 200, { componentTreeRequired: true, message: "XKT preview/component tree is waiting for NodeDcBimConverter.", sourceFormat, sourceSrc: srcFromUploadPath(sourcePath), status: "conversion_required", targetFormat: "xkt", updatedAt: nowIso() }); return; } sendJSON(res, 200, manifest); }; const buildProjectPayload = (body) => { const now = new Date().toISOString(); const id = `proj_${crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)}`; const name = typeof body.name === "string" ? body.name.trim() : ""; if (!name) { throw new Error("Project name is required"); } const type = body.type === "template" ? "template" : "project"; const models = Array.isArray(body.models) ? body.models : []; const viewerState = body.viewerState && typeof body.viewerState === "object" ? body.viewerState : {}; const design = body.design && typeof body.design === "object" ? body.design : {}; const meta = body.meta && typeof body.meta === "object" ? body.meta : {description: "", tags: []}; return { id, name, type, createdAt: now, updatedAt: now, ownerId: null, shares: [], models, viewerState, design, meta }; }; const buildUpdatedProject = (body, existing) => { const now = new Date().toISOString(); const name = typeof body.name === "string" ? body.name.trim() : ""; if (!name) { throw new Error("Project name is required"); } const type = body.type === "template" ? "template" : (body.type === "project" ? "project" : (existing.type || "project")); const models = Array.isArray(body.models) ? body.models : (existing.models || []); const viewerState = body.viewerState && typeof body.viewerState === "object" ? body.viewerState : (existing.viewerState || {}); const design = body.design && typeof body.design === "object" ? body.design : (existing.design || {}); const meta = body.meta && typeof body.meta === "object" ? body.meta : (existing.meta || {description: "", tags: []}); return { id: existing.id, name, type, createdAt: existing.createdAt || now, updatedAt: now, ownerId: existing.ownerId ?? null, shares: Array.isArray(existing.shares) ? existing.shares : [], models, viewerState, design, meta }; }; const handleGetProjects = async (res, searchParams) => { const filterType = searchParams.get("type"); const items = await readIndex(); const filtered = filterType ? items.filter((item) => item.type === filterType) : items; sendJSON(res, 200, filtered); }; const handleGetProject = async (res, projectId) => { const safeId = safeProjectId(projectId); if (!safeId) { sendText(res, 400, "Invalid project id"); return; } const projectPath = path.join(DATA_DIR, `${safeId}.json`); if (!existsSync(projectPath)) { sendText(res, 404, "Project not found"); return; } try { const raw = await fs.readFile(projectPath, "utf8"); const parsed = JSON.parse(raw); sendJSON(res, 200, parsed); } catch (err) { console.error("[server] failed to read project file", err); sendText(res, 500, "Failed to read project"); } }; const handleCreateProject = async (req, res) => { let payload; try { payload = await parseJSONBody(req); } catch (err) { sendText(res, 400, err.message || "Invalid request body"); return; } try { const project = buildProjectPayload(payload); await processUploads(project); const projectPath = path.join(DATA_DIR, `${project.id}.json`); await fs.writeFile(projectPath, JSON.stringify(project, null, 2), "utf8"); const indexItems = await readIndex(); const summary = { id: project.id, name: project.name, type: project.type, ownerId: project.ownerId, createdAt: project.createdAt, updatedAt: project.updatedAt }; const nextIndex = [summary, ...indexItems.filter((item) => item.id !== project.id)]; await writeIndex(nextIndex); sendJSON(res, 201, project); } catch (err) { console.error("[server] failed to create project", err); sendText(res, 400, err.message || "Failed to create project"); } }; const handlePatchProject = async (req, res, projectId) => { const safeId = safeProjectId(projectId); if (!safeId) { sendText(res, 400, "Invalid project id"); return; } const projectPath = path.join(DATA_DIR, `${safeId}.json`); if (!existsSync(projectPath)) { sendText(res, 404, "Project not found"); return; } let payload = {}; try { payload = await parseJSONBody(req); } catch (err) { sendText(res, 400, err.message || "Invalid JSON"); return; } if (!payload || typeof payload !== "object") { sendText(res, 400, "Invalid payload"); return; } try { const raw = await fs.readFile(projectPath, "utf8"); const project = JSON.parse(raw); const now = new Date().toISOString(); if (typeof payload.name === "string") { project.name = payload.name.trim(); } if (payload.type === "template" || payload.type === "project") { project.type = payload.type; } project.updatedAt = now; await fs.writeFile(projectPath, JSON.stringify(project, null, 2), "utf8"); const items = await readIndex(); const nextIndex = items.map((item) => { if (item.id !== safeId) return item; return { ...item, name: project.name, type: project.type, updatedAt: project.updatedAt }; }); await writeIndex(nextIndex); sendJSON(res, 200, project); } catch (err) { console.error("[server] failed to patch project", err); sendText(res, 500, "Failed to update project"); } }; const handlePutProject = async (req, res, projectId) => { const safeId = safeProjectId(projectId); if (!safeId) { sendText(res, 400, "Invalid project id"); return; } const projectPath = path.join(DATA_DIR, `${safeId}.json`); if (!existsSync(projectPath)) { sendText(res, 404, "Project not found"); return; } let payload = {}; try { payload = await parseJSONBody(req); } catch (err) { sendText(res, 400, err.message || "Invalid JSON"); return; } try { const raw = await fs.readFile(projectPath, "utf8"); const existing = JSON.parse(raw); const updated = buildUpdatedProject(payload, { ...existing, id: safeId }); await fs.writeFile(projectPath, JSON.stringify(updated, null, 2), "utf8"); const items = await readIndex(); const nextIndex = items.map((item) => { if (item.id !== safeId) return item; return { ...item, name: updated.name, type: updated.type, updatedAt: updated.updatedAt }; }); await writeIndex(nextIndex); sendJSON(res, 200, updated); } catch (err) { console.error("[server] failed to save project", err); sendText(res, 500, "Failed to save project"); } }; const handleDeleteProject = async (res, projectId) => { const safeId = safeProjectId(projectId); if (!safeId) { sendText(res, 400, "Invalid project id"); return; } const projectPath = path.join(DATA_DIR, `${safeId}.json`); if (!existsSync(projectPath)) { sendText(res, 404, "Project not found"); return; } try { await fs.unlink(projectPath); } catch (err) { console.error("[server] failed to delete project file", err); } // remove uploads folder if exists const uploadDir = path.join(UPLOADS_DIR, safeId); try { const stat = await fs.stat(uploadDir); if (stat.isDirectory()) { await fs.rm(uploadDir, {recursive: true, force: true}); } } catch (_) { // ignore } const items = await readIndex(); const filtered = items.filter((item) => item.id !== safeId); await writeIndex(filtered); sendJSON(res, 200, {deleted: true, id: safeId}); }; const requestHandler = async (req, res) => { const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); if (req.method === "OPTIONS") { setCors(res); res.statusCode = 204; res.end(); return; } if (url.pathname.startsWith("/api/projects")) { if (req.method === "GET" && url.pathname === "/api/projects") { return handleGetProjects(res, url.searchParams); } if (req.method === "GET" && /^\/api\/projects\/[^/]+/.test(url.pathname)) { const projectId = url.pathname.split("/")[3]; return handleGetProject(res, projectId); } if (req.method === "POST" && url.pathname === "/api/projects") { return handleCreateProject(req, res); } if (req.method === "PATCH" && /^\/api\/projects\/[^/]+/.test(url.pathname)) { const projectId = url.pathname.split("/")[3]; return handlePatchProject(req, res, projectId); } if (req.method === "PUT" && /^\/api\/projects\/[^/]+/.test(url.pathname)) { const projectId = url.pathname.split("/")[3]; return handlePutProject(req, res, projectId); } if (req.method === "DELETE" && /^\/api\/projects\/[^/]+/.test(url.pathname)) { const projectId = url.pathname.split("/")[3]; return handleDeleteProject(res, projectId); } sendText(res, 405, "Method Not Allowed"); return; } if (req.method === "POST" && url.pathname === "/api/uploads") { return handleRawUpload(req, res, url); } if (req.method === "GET" && url.pathname === "/api/conversions/status") { return handleConversionStatus(res, url.searchParams); } await serveStatic(req, res, url); }; const start = async () => { await ensureStorage(); const startServer = (port, retries = 3) => { const server = http.createServer((req, res) => { requestHandler(req, res).catch((err) => { console.error("[server] unhandled error", err); sendText(res, 500, "Internal Server Error"); }); }); server.on("error", (err) => { if (err.code === "EADDRINUSE" && retries > 0) { const nextPort = port + 1; console.warn(`[server] port ${port} is busy, trying ${nextPort}...`); startServer(nextPort, retries - 1); } else { console.error("[server] failed to start", err); process.exit(1); } }); server.listen(port, () => { console.log(`[server] listening on http://localhost:${port}`); }); }; startServer(PORT); }; start().catch((err) => { console.error("[server] failed to start", err); process.exit(1); });