NODEDC_BIM_VIEWER/server/index.js

846 lines
27 KiB
JavaScript

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 truncateLogValue = (value, maxLength = 2400) => {
const text = typeof value === "string" ? value : JSON.stringify(value);
return text.length > maxLength ? `${text.slice(0, maxLength)}` : text;
};
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 normalizeUploadSrcValue = (src) => {
const resolved = resolveUploadSrc(src);
return resolved ? srcFromUploadPath(resolved) : null;
};
const findManifestForModelSrc = async (src) => {
const uploadPath = resolveUploadSrc(src);
if (!uploadPath) {
return null;
}
const sourceSrc = srcFromUploadPath(uploadPath);
const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(uploadPath).toLowerCase());
if (sourceFormat) {
return {
manifestPath: manifestPathForSource(uploadPath),
modelPath: uploadPath,
sourceSrc
};
}
const directManifestPath = manifestPathForSource(uploadPath);
if (existsSync(directManifestPath)) {
return {
manifestPath: directManifestPath,
modelPath: uploadPath,
sourceSrc
};
}
let entries = [];
try {
entries = await fs.readdir(path.dirname(uploadPath));
} catch (_) {
return {
manifestPath: directManifestPath,
modelPath: uploadPath,
sourceSrc
};
}
for (const entry of entries) {
if (!entry.endsWith(".beam.json")) {
continue;
}
const candidatePath = path.join(path.dirname(uploadPath), entry);
const manifest = await readJSONFile(candidatePath);
if (!manifest || typeof manifest !== "object") {
continue;
}
const relatedSrcs = [
manifest.sourceSrc,
manifest.artifactSrc,
manifest.fallbackArtifactSrc,
manifest.metadataSrc
].map(normalizeUploadSrcValue).filter(Boolean);
if (relatedSrcs.includes(sourceSrc)) {
return {
manifestPath: candidatePath,
modelPath: uploadPath,
sourceSrc: normalizeUploadSrcValue(manifest.sourceSrc) || sourceSrc
};
}
}
return {
manifestPath: directManifestPath,
modelPath: uploadPath,
sourceSrc
};
};
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 handleGetModelSettings = async (res, searchParams) => {
const manifestRef = await findManifestForModelSrc(searchParams.get("src"));
if (!manifestRef) {
sendText(res, 400, "Invalid model path");
return;
}
if (!existsSync(manifestRef.modelPath)) {
sendText(res, 404, "Model file not found");
return;
}
const manifest = await readJSONFile(manifestRef.manifestPath);
sendJSON(res, 200, {
sourceSrc: manifest?.sourceSrc || manifestRef.sourceSrc,
artifactSrc: manifest?.artifactSrc || null,
fallbackArtifactSrc: manifest?.fallbackArtifactSrc || null,
viewerSettings: manifest?.viewerSettings || null
});
};
const handlePutModelSettings = async (req, res, searchParams) => {
const manifestRef = await findManifestForModelSrc(searchParams.get("src"));
if (!manifestRef) {
sendText(res, 400, "Invalid model path");
return;
}
if (!existsSync(manifestRef.modelPath)) {
sendText(res, 404, "Model file not found");
return;
}
let payload = {};
try {
payload = await parseJSONBody(req);
} catch (err) {
sendText(res, 400, err.message || "Invalid JSON");
return;
}
const viewerSettings = payload?.viewerSettings;
if (!viewerSettings || typeof viewerSettings !== "object" || Array.isArray(viewerSettings)) {
sendText(res, 400, "viewerSettings object is required");
return;
}
const existing = await readJSONFile(manifestRef.manifestPath) || {};
const now = nowIso();
const nextManifest = {
...existing,
sourceSrc: existing.sourceSrc || manifestRef.sourceSrc,
status: existing.status || "ready",
viewerSettings: {
...viewerSettings,
updatedAt: viewerSettings.updatedAt || now
},
viewerSettingsUpdatedAt: now,
updatedAt: existing.updatedAt || now
};
try {
await writeJSONFile(manifestRef.manifestPath, nextManifest);
sendJSON(res, 200, {
sourceSrc: nextManifest.sourceSrc,
artifactSrc: nextManifest.artifactSrc || null,
fallbackArtifactSrc: nextManifest.fallbackArtifactSrc || null,
viewerSettings: nextManifest.viewerSettings
});
} catch (err) {
console.error("[server] failed to save model settings", err);
sendText(res, 500, "Failed to save model settings");
}
};
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 === "GET" && url.pathname === "/") {
console.log("[viewer-request]", truncateLogValue(`${url.pathname}${url.search}`));
}
if (req.method === "OPTIONS") {
setCors(res);
res.statusCode = 204;
res.end();
return;
}
if (req.method === "POST" && url.pathname === "/api/client-log") {
const payload = await parseJSONBody(req);
console.log(
"[viewer-client]",
payload?.level || "info",
payload?.event || "event",
truncateLogValue(payload || {})
);
sendJSON(res, 200, {ok: true});
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);
}
if (req.method === "GET" && url.pathname === "/api/model-settings") {
return handleGetModelSettings(res, url.searchParams);
}
if (req.method === "PUT" && url.pathname === "/api/model-settings") {
return handlePutModelSettings(req, 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);
});