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); function parseBooleanEnv(value, fallback = false) { if (value === undefined || value === null || value === "") { return fallback; } return ["1", "true", "yes", "on"].includes(String(value).toLowerCase()); } const DATA_ROOT = process.env.NODEDC_BIM_DATA_ROOT ? path.resolve(process.env.NODEDC_BIM_DATA_ROOT) : path.join(__dirname, "data"); const DATA_DIR = path.join(DATA_ROOT, "projects"); const UPLOADS_DIR = path.join(DATA_ROOT, "uploads"); const SHARES_DIR = path.join(DATA_ROOT, "shares"); const MODELS_DIR = path.join(DATA_ROOT, "models"); const COMMENTS_DIR = path.join(DATA_ROOT, "comments"); const INDEX_FILE = path.join(DATA_DIR, "index.json"); const SHARES_INDEX_FILE = path.join(SHARES_DIR, "index.json"); const MODEL_REGISTRY_FILE = path.join(MODELS_DIR, "registry.json"); const COMMENTS_INDEX_FILE = path.join(COMMENTS_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 BIM_SERVICE_SLUG = process.env.NODEDC_BIM_SERVICE_SLUG || "bim-viewer"; const BIM_AUTH_REQUIRED = parseBooleanEnv( process.env.NODEDC_BIM_AUTH_REQUIRED ?? process.env.NODEDC_BIM_AUTH_ENABLED, false ); const BIM_PUBLIC_BASE_URL = (process.env.NODEDC_BIM_PUBLIC_URL || process.env.BIM_VIEWER_BASE_URL || "").replace(/\/$/, ""); const LAUNCHER_BASE_URL = ( process.env.NODEDC_LAUNCHER_BASE_URL || process.env.LAUNCHER_BASE_URL || "http://localhost:5173" ).replace(/\/$/, ""); const LAUNCHER_INTERNAL_BASE_URL = ( process.env.NODEDC_LAUNCHER_INTERNAL_URL || process.env.NODEDC_LAUNCHER_BASE_URL || process.env.LAUNCHER_BASE_URL || "http://localhost:5173" ).replace(/\/$/, ""); const INTERNAL_ACCESS_TOKEN = process.env.NODEDC_INTERNAL_ACCESS_TOKEN || process.env.NODEDC_PLATFORM_SERVICE_TOKEN || ""; const BIM_SESSION_COOKIE = process.env.NODEDC_BIM_SESSION_COOKIE || "nodedc_bim_session"; const BIM_SESSION_TTL_MS = Number(process.env.NODEDC_BIM_SESSION_TTL_MS || 12 * 60 * 60 * 1000); const BIM_COOKIE_SECURE = parseBooleanEnv(process.env.NODEDC_BIM_COOKIE_SECURE, false); const BIM_COOKIE_SAMESITE = process.env.NODEDC_BIM_COOKIE_SAMESITE || (BIM_COOKIE_SECURE ? "None" : "Lax"); const CORS_ALLOWED_ORIGINS = String(process.env.NODEDC_BIM_ALLOWED_ORIGINS || "") .split(",") .map((origin) => origin.trim()) .filter(Boolean); const bimSessions = new Map(); 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(); let modelRegistryMutationQueue = Promise.resolve(); const ensureStorage = async () => { await fs.mkdir(DATA_DIR, {recursive: true}); await fs.mkdir(UPLOADS_DIR, {recursive: true}); await fs.mkdir(SHARES_DIR, {recursive: true}); await fs.mkdir(MODELS_DIR, {recursive: true}); await fs.mkdir(COMMENTS_DIR, {recursive: true}); try { await fs.access(INDEX_FILE); } catch (err) { await fs.writeFile(INDEX_FILE, "[]", "utf8"); } try { await fs.access(SHARES_INDEX_FILE); } catch (err) { await fs.writeFile(SHARES_INDEX_FILE, "[]", "utf8"); } try { await fs.access(MODEL_REGISTRY_FILE); } catch (err) { await fs.writeFile(MODEL_REGISTRY_FILE, JSON.stringify(createEmptyModelRegistry(), null, 2), "utf8"); } try { await fs.access(COMMENTS_INDEX_FILE); } catch (err) { await fs.writeFile(COMMENTS_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 readSharesIndex = async () => { try { const raw = await fs.readFile(SHARES_INDEX_FILE, "utf8"); const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch (err) { console.error("[server] failed to read shares index.json", err); return []; } }; const writeSharesIndex = async (items) => { await fs.writeFile(SHARES_INDEX_FILE, JSON.stringify(items, null, 2), "utf8"); }; const readCommentsIndex = async () => { try { const raw = await fs.readFile(COMMENTS_INDEX_FILE, "utf8"); const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch (err) { if (err?.code === "ENOENT") { await fs.mkdir(COMMENTS_DIR, {recursive: true}); await fs.writeFile(COMMENTS_INDEX_FILE, "[]", "utf8"); return []; } console.error("[server] failed to read comments index.json", err); return []; } }; const writeCommentsIndex = async (items) => { await fs.mkdir(COMMENTS_DIR, {recursive: true}); await fs.writeFile(COMMENTS_INDEX_FILE, JSON.stringify(items, null, 2), "utf8"); }; const createEmptyModelRegistry = () => ({ version: 1, assets: [], refs: [], shares: [] }); const normalizeModelRegistry = (registry) => ({ version: Number(registry?.version) || 1, assets: Array.isArray(registry?.assets) ? registry.assets : [], refs: Array.isArray(registry?.refs) ? registry.refs : [], shares: Array.isArray(registry?.shares) ? registry.shares : [] }); const readModelRegistry = async () => { try { const raw = await fs.readFile(MODEL_REGISTRY_FILE, "utf8"); return normalizeModelRegistry(JSON.parse(raw)); } catch (err) { console.error("[server] failed to read model registry", err); return createEmptyModelRegistry(); } }; const writeModelRegistry = async (registry) => { await fs.writeFile(MODEL_REGISTRY_FILE, JSON.stringify(normalizeModelRegistry(registry), null, 2), "utf8"); }; const mutateModelRegistry = async (mutator) => { const mutation = modelRegistryMutationQueue.then(async () => { const registry = await readModelRegistry(); const result = await mutator(registry); await writeModelRegistry(registry); return result; }); modelRegistryMutationQueue = mutation.catch(() => {}); return mutation; }; const stringOrNull = (value) => { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed || null; }; const normalizeRegistryUser = (user) => { if (!user || typeof user !== "object") { return null; } const id = stringOrNull(user.id) || stringOrNull(user.authentikUserId) || stringOrNull(user.subject) || stringOrNull(user.sub) || stringOrNull(user.email); if (!id) { return null; } return { id, email: stringOrNull(user.email), name: stringOrNull(user.name) || stringOrNull(user.preferredUsername), subject: stringOrNull(user.subject) || stringOrNull(user.sub), authentikUserId: stringOrNull(user.authentikUserId), groups: Array.isArray(user.groups) ? user.groups.filter((group) => typeof group === "string") : [] }; }; const localDevRegistryUser = () => ({ id: "local-dev", email: null, name: "Local BIM user", subject: null, authentikUserId: null, groups: [] }); const getRequestRegistryUser = (req) => { const sessionUser = normalizeRegistryUser(getCurrentBimSession(req)?.user); if (sessionUser) { return sessionUser; } return BIM_AUTH_REQUIRED ? null : localDevRegistryUser(); }; const shortHash = (value, length = 20) => crypto.createHash("sha1").update(String(value || "")).digest("hex").slice(0, length); const registryAssetKey = (identity) => { const projectId = safeProjectId(identity?.projectId); const assetId = safeAssetId(identity?.assetId); return projectId && assetId ? `${projectId}:${assetId}` : null; }; const normalizeAssetIdentity = (identity) => { const projectId = safeProjectId(identity?.projectId); const assetId = safeAssetId(identity?.assetId); if (!projectId || !assetId) { return null; } return { projectId, assetId, assetKey: registryAssetKey({projectId, assetId}), legacy: !!identity?.legacy }; }; const getAssetIdentityFromUploadPath = (uploadPath) => { if (!uploadPath || !isPathInsideOrEqual(uploadPath, UPLOADS_DIR)) { return null; } const parts = path.relative(UPLOADS_DIR, uploadPath).split(path.sep).filter(Boolean); if (parts.length >= 3) { return normalizeAssetIdentity({ projectId: parts[0], assetId: parts[1] }); } if (parts.length >= 2) { return normalizeAssetIdentity({ projectId: parts[0], assetId: `legacy_${shortHash(parts.join("/"))}`, legacy: true }); } return null; }; const getAssetIdentityFromSrc = (src) => { const uploadPath = resolveUploadSrc(src); return uploadPath ? getAssetIdentityFromUploadPath(uploadPath) : null; }; const getAssetIdentityFromAssetDir = (assetDir) => { if (!assetDir || !isPathInsideOrEqual(assetDir, UPLOADS_DIR)) { return null; } const parts = path.relative(UPLOADS_DIR, assetDir).split(path.sep).filter(Boolean); if (parts.length < 2) { return null; } return normalizeAssetIdentity({ projectId: parts[0], assetId: parts[1] }); }; const resolveAssetIdentityFromPayload = (payload) => { const direct = normalizeAssetIdentity({ projectId: payload?.projectId, assetId: payload?.assetId }); if (direct) { return direct; } const srcs = collectUploadSrcValues(payload); for (const src of srcs) { const identity = getAssetIdentityFromSrc(src); if (identity) { return identity; } } return null; }; const summarizeRegistryUser = (user) => user ? { id: user.id, email: user.email || null, name: user.name || null, subject: user.subject || null, authentikUserId: user.authentikUserId || null } : null; const registryAssetFromVersionRecord = (versionRecord) => { const identity = normalizeAssetIdentity(versionRecord); if (!identity) { return null; } const sourceSrc = normalizeUploadSrcValue(versionRecord?.sourceSrc || versionRecord?.downloadSrc || versionRecord?.src); const downloadSrc = normalizeUploadSrcValue(versionRecord?.downloadSrc || versionRecord?.sourceSrc || versionRecord?.src) || sourceSrc; return { ...identity, originalFilename: stringOrNull(versionRecord?.originalFilename) || (sourceSrc ? path.basename(sourceSrc) : null), sourceSrc, downloadSrc, type: stringOrNull(versionRecord?.sourceFormat) || guessViewerTypeFromSrc(sourceSrc || "", "file"), status: stringOrNull(versionRecord?.status) || "ready", sha256: stringOrNull(versionRecord?.sha256), currentVersion: Number(versionRecord?.version) || null, currentVersionId: stringOrNull(versionRecord?.versionId), uploadedAt: stringOrNull(versionRecord?.uploadedAt) || nowIso(), versionRecord: { ...versionRecord, sourceSrc, downloadSrc } }; }; const upsertRegistryAsset = (registry, assetInput) => { const identity = normalizeAssetIdentity(assetInput); if (!identity) { return null; } const now = nowIso(); const existingIndex = registry.assets.findIndex((asset) => asset?.assetKey === identity.assetKey); const existing = existingIndex >= 0 ? registry.assets[existingIndex] : null; const versions = Array.isArray(existing?.versions) ? [...existing.versions] : []; const versionRecord = assetInput?.versionRecord; if (versionRecord?.versionId) { const normalizedVersion = { ...versionRecord, sourceSrc: normalizeUploadSrcValue(versionRecord.sourceSrc || versionRecord.src) || versionRecord.sourceSrc || null, downloadSrc: normalizeUploadSrcValue(versionRecord.downloadSrc || versionRecord.sourceSrc || versionRecord.src) || versionRecord.downloadSrc || null }; const versionIndex = versions.findIndex((version) => version?.versionId === normalizedVersion.versionId); if (versionIndex >= 0) { versions[versionIndex] = {...versions[versionIndex], ...normalizedVersion}; } else { versions.push(normalizedVersion); } versions.sort((first, second) => Number(first?.version || 0) - Number(second?.version || 0)); } const nextAsset = { ...(existing || {}), assetKey: identity.assetKey, assetId: identity.assetId, projectId: identity.projectId, legacy: !!identity.legacy, originalFilename: assetInput?.originalFilename || existing?.originalFilename || null, sourceSrc: assetInput?.sourceSrc || existing?.sourceSrc || null, downloadSrc: assetInput?.downloadSrc || existing?.downloadSrc || assetInput?.sourceSrc || null, type: assetInput?.type || existing?.type || null, status: assetInput?.status || existing?.status || "ready", sha256: assetInput?.sha256 || existing?.sha256 || null, currentVersion: assetInput?.currentVersion || existing?.currentVersion || null, currentVersionId: assetInput?.currentVersionId || existing?.currentVersionId || null, createdAt: existing?.createdAt || assetInput?.uploadedAt || now, updatedAt: now, deletedAt: null, physicallyDeletedAt: null, versions }; if (existingIndex >= 0) { registry.assets[existingIndex] = nextAsset; } else { registry.assets.push(nextAsset); } return nextAsset; }; const upsertRegistryAssetFromManifest = async (registry, identity) => { const normalized = normalizeAssetIdentity(identity); if (!normalized || normalized.legacy) { return null; } const assetDir = path.join(UPLOADS_DIR, normalized.projectId, normalized.assetId); const manifest = await readAssetManifest(assetDir); if (!manifest) { return upsertRegistryAsset(registry, { ...normalized, originalFilename: null, status: "unknown" }); } const versions = Array.isArray(manifest.versions) ? manifest.versions : []; const currentVersion = versions.find((version) => version?.versionId === manifest.currentVersionId) || versions.find((version) => Number(version?.version) === Number(manifest.currentVersion)) || versions[versions.length - 1] || null; const asset = upsertRegistryAsset(registry, { ...normalized, originalFilename: manifest.originalFilename || currentVersion?.originalFilename || null, sourceSrc: normalizeUploadSrcValue(currentVersion?.sourceSrc || currentVersion?.src) || null, downloadSrc: normalizeUploadSrcValue(currentVersion?.downloadSrc || currentVersion?.sourceSrc || currentVersion?.src) || null, type: currentVersion?.sourceFormat || guessViewerTypeFromSrc(currentVersion?.sourceSrc || currentVersion?.downloadSrc || "", "file"), status: currentVersion?.status || "ready", sha256: currentVersion?.sha256 || null, currentVersion: manifest.currentVersion || currentVersion?.version || null, currentVersionId: manifest.currentVersionId || currentVersion?.versionId || null }); for (const version of versions) { upsertRegistryAsset(registry, { ...normalized, versionRecord: version }); } return asset; }; const upsertRegistryUserRef = (registry, identity, user, options = {}) => { const normalized = normalizeAssetIdentity(identity); const registryUser = normalizeRegistryUser(user); if (!normalized || !registryUser) { return null; } const kind = stringOrNull(options.kind) || "owner"; const source = stringOrNull(options.source) || "upload"; const refId = `user:${normalized.assetKey}:${registryUser.id}:${kind}:${source}`; const now = nowIso(); const existingIndex = registry.refs.findIndex((ref) => ref?.id === refId); const existing = existingIndex >= 0 ? registry.refs[existingIndex] : null; const nextRef = { ...(existing || {}), id: refId, assetKey: normalized.assetKey, assetId: normalized.assetId, projectId: normalized.projectId, user: summarizeRegistryUser(registryUser), kind, source, versionId: stringOrNull(options.versionId) || existing?.versionId || null, src: normalizeUploadSrcValue(options.src) || existing?.src || null, settingsSrc: normalizeUploadSrcValue(options.settingsSrc) || existing?.settingsSrc || null, createdAt: existing?.createdAt || now, updatedAt: now, lastOpenedAt: options.touch ? now : existing?.lastOpenedAt || null, deletedAt: null }; if (existingIndex >= 0) { registry.refs[existingIndex] = nextRef; } else { registry.refs.push(nextRef); } return nextRef; }; const markRegistryUserRefsDeleted = (registry, identity, user) => { const normalized = normalizeAssetIdentity(identity); const registryUser = normalizeRegistryUser(user); if (!normalized || !registryUser) { return []; } const now = nowIso(); const removed = []; for (const ref of registry.refs) { if (ref?.assetKey !== normalized.assetKey || ref?.user?.id !== registryUser.id || ref.deletedAt) { continue; } ref.deletedAt = now; ref.updatedAt = now; removed.push(ref); } return removed; }; const upsertRegistryShareRef = (registry, identity, share) => { const normalized = normalizeAssetIdentity(identity); if (!normalized || !share?.token) { return null; } const refId = `share:${share.token}`; const now = nowIso(); const existingIndex = registry.shares.findIndex((ref) => ref?.id === refId || ref?.token === share.token); const existing = existingIndex >= 0 ? registry.shares[existingIndex] : null; const nextRef = { ...(existing || {}), id: refId, token: share.token, assetKey: normalized.assetKey, assetId: normalized.assetId, projectId: normalized.projectId, access: share.access || "public-readonly", src: normalizeUploadSrcValue(share.src) || null, settingsSrc: normalizeUploadSrcValue(share.settingsSrc || share.src) || null, versionId: stringOrNull(share.versionId), createdAt: existing?.createdAt || share.createdAt || now, updatedAt: now, createdBy: share.createdBy || null, revokedAt: null }; if (existingIndex >= 0) { registry.shares[existingIndex] = nextRef; } else { registry.shares.push(nextRef); } return nextRef; }; const getActiveRegistryRefs = (registry, identity) => { const normalized = normalizeAssetIdentity(identity); if (!normalized) { return {userRefs: [], shareRefs: []}; } return { userRefs: registry.refs.filter((ref) => ref?.assetKey === normalized.assetKey && !ref.deletedAt), shareRefs: registry.shares.filter((ref) => ref?.assetKey === normalized.assetKey && !ref.revokedAt) }; }; const getActiveRegistryRefsForVersion = (registry, identity, versionRecord, targetSrcs = new Set()) => { const normalized = normalizeAssetIdentity(identity); if (!normalized) { return {userRefs: [], shareRefs: []}; } const normalizedTargetSrcs = new Set([...targetSrcs].map(normalizeUploadSrcValue).filter(Boolean)); const versionId = stringOrNull(versionRecord?.versionId); const matchesVersionOrSrc = (ref) => { if (versionId && ref?.versionId === versionId) { return true; } const refSrcs = collectUploadSrcValues(ref); return [...refSrcs].some((src) => normalizedTargetSrcs.has(src)); }; return { userRefs: registry.refs.filter((ref) => ref?.assetKey === normalized.assetKey && !ref.deletedAt && matchesVersionOrSrc(ref)), shareRefs: registry.shares.filter((ref) => ref?.assetKey === normalized.assetKey && !ref.revokedAt && matchesVersionOrSrc(ref)) }; }; const summarizeActiveRegistryRefs = (activeRefs) => ({ userRefs: activeRefs.userRefs.length, shareRefs: activeRefs.shareRefs.length, total: activeRefs.userRefs.length + activeRefs.shareRefs.length }); const markRegistryAssetPhysicallyDeleted = (registry, identity) => { const normalized = normalizeAssetIdentity(identity); if (!normalized) { return null; } const asset = registry.assets.find((item) => item?.assetKey === normalized.assetKey); if (!asset) { return null; } const now = nowIso(); asset.deletedAt = asset.deletedAt || now; asset.physicallyDeletedAt = now; asset.updatedAt = now; return asset; }; const removeRegistryVersion = (registry, identity, versionRecord) => { const normalized = normalizeAssetIdentity(identity); if (!normalized || !versionRecord?.versionId) { return null; } const asset = registry.assets.find((item) => item?.assetKey === normalized.assetKey); if (!asset || !Array.isArray(asset.versions)) { return null; } asset.versions = asset.versions.filter((version) => version?.versionId !== versionRecord.versionId); const nextCurrent = asset.versions.toSorted((first, second) => Number(second?.version || 0) - Number(first?.version || 0))[0] || null; asset.currentVersion = nextCurrent?.version || null; asset.currentVersionId = nextCurrent?.versionId || null; asset.updatedAt = nowIso(); return asset; }; const buildModelListItem = (asset, ref, registry) => { const shareCount = registry.shares.filter((share) => share?.assetKey === asset?.assetKey && !share.revokedAt).length; return { assetId: asset.assetId, projectId: asset.projectId, assetKey: asset.assetKey, name: asset.originalFilename || path.basename(asset.downloadSrc || asset.sourceSrc || asset.assetId), src: asset.downloadSrc || asset.sourceSrc || null, settingsSrc: asset.sourceSrc || asset.downloadSrc || null, type: asset.type || guessViewerTypeFromSrc(asset.downloadSrc || asset.sourceSrc || "", null), status: asset.status || "ready", currentVersion: asset.currentVersion || null, currentVersionId: asset.currentVersionId || null, updatedAt: asset.updatedAt || asset.createdAt || null, ref: { id: ref.id, kind: ref.kind, source: ref.source, createdAt: ref.createdAt, updatedAt: ref.updatedAt }, shareCount }; }; 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 resolveCorsOrigin = (origin) => { if (!origin) { return "*"; } if (!CORS_ALLOWED_ORIGINS.length || CORS_ALLOWED_ORIGINS.includes(origin)) { return origin; } return null; }; const setCors = (reqOrRes, maybeRes = null) => { const req = maybeRes ? reqOrRes : null; const res = maybeRes || reqOrRes; const origin = req?.headers?.origin || res.__nodedcCorsOrigin || ""; const allowedOrigin = resolveCorsOrigin(origin) || "null"; res.__nodedcCorsOrigin = allowedOrigin; res.setHeader("Access-Control-Allow-Origin", allowedOrigin); res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With"); res.setHeader("Access-Control-Allow-Credentials", "true"); if (origin) { res.setHeader("Vary", "Origin"); } }; 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 randomBase64Url = (bytes = 24) => crypto.randomBytes(bytes).toString("base64url"); const sanitizeReturnTo = (value, fallback = "/") => { if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) { return fallback; } return value; }; const parseCookies = (cookieHeader) => { if (!cookieHeader) { return {}; } return Object.fromEntries( String(cookieHeader).split(";").flatMap((part) => { const separatorIndex = part.indexOf("="); if (separatorIndex === -1) { return []; } const key = part.slice(0, separatorIndex).trim(); const value = part.slice(separatorIndex + 1).trim(); return [[key, decodeURIComponent(value)]]; }) ); }; const buildCookieHeader = (name, value, maxAgeSeconds) => { const parts = [ `${name}=${encodeURIComponent(value)}`, "Path=/", "HttpOnly", `SameSite=${BIM_COOKIE_SAMESITE}`, `Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}` ]; if (BIM_COOKIE_SECURE) { parts.push("Secure"); } return parts.join("; "); }; const clearBimSessionCookie = (res) => { res.setHeader("Set-Cookie", buildCookieHeader(BIM_SESSION_COOKIE, "", 0)); }; const pruneExpiredBimSessions = () => { const now = Date.now(); for (const [id, session] of bimSessions.entries()) { if (!session?.expiresAt || session.expiresAt <= now) { bimSessions.delete(id); } } }; const createBimSession = (res, user) => { pruneExpiredBimSessions(); const id = randomBase64Url(32); const session = { id, user, createdAt: nowIso(), expiresAt: Date.now() + BIM_SESSION_TTL_MS }; bimSessions.set(id, session); res.setHeader("Set-Cookie", buildCookieHeader(BIM_SESSION_COOKIE, id, BIM_SESSION_TTL_MS / 1000)); return session; }; const getCurrentBimSession = (req) => { const sessionId = parseCookies(req.headers.cookie || "")[BIM_SESSION_COOKIE]; if (!sessionId) { return null; } const session = bimSessions.get(sessionId); if (!session || session.expiresAt <= Date.now()) { bimSessions.delete(sessionId); return null; } return session; }; const getRequestBaseUrl = (req) => { if (BIM_PUBLIC_BASE_URL) { return BIM_PUBLIC_BASE_URL; } const proto = req.headers["x-forwarded-proto"] || "http"; const host = req.headers["x-forwarded-host"] || req.headers.host || `localhost:${PORT}`; return `${proto}://${host}`.replace(/\/$/, ""); }; const buildLauncherLoginUrl = (req, nextPath = "/") => { const launchPath = `/api/services/${encodeURIComponent(BIM_SERVICE_SLUG)}/launch`; const launchUrl = new URL(launchPath, LAUNCHER_BASE_URL); launchUrl.searchParams.set("returnTo", sanitizeReturnTo(nextPath)); const loginUrl = new URL("/auth/login", LAUNCHER_BASE_URL); loginUrl.searchParams.set("returnTo", `${launchUrl.pathname}${launchUrl.search}`); return loginUrl.toString(); }; const sendAuthRequired = (req, res, url) => { const nextPath = sanitizeReturnTo(`${url.pathname}${url.search || ""}`); if (!INTERNAL_ACCESS_TOKEN) { sendText(res, 503, "BIM auth is enabled but NODEDC_INTERNAL_ACCESS_TOKEN is not configured."); return; } if (isHtmlNavigation(req, url)) { res.statusCode = 302; res.setHeader("Location", buildLauncherLoginUrl(req, nextPath)); res.end(); return; } sendJSON(res, 401, { ok: false, authenticated: false, error: "bim_auth_required", loginUrl: buildLauncherLoginUrl(req, nextPath) }); }; const isHtmlNavigation = (req, url) => { if (req.method !== "GET" && req.method !== "HEAD") { return false; } if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/")) { return false; } const accept = typeof req.headers.accept === "string" ? req.headers.accept : ""; return url.pathname === "/" || (!path.extname(url.pathname) && accept.includes("text/html")); }; const isPublicSharePath = (url) => /^\/share\/[^/]+\/?$/.test(url.pathname); const requestNeedsBimSession = (req, url) => { if (!BIM_AUTH_REQUIRED) { return false; } if (url.pathname.startsWith("/auth/") || url.pathname === "/api/auth/session") { return false; } if (req.method === "GET" && /^\/api\/shares\/[^/]+\/?$/.test(url.pathname)) { return false; } if (isPublicSharePath(url)) { return false; } if (url.pathname === "/api/client-log") { return false; } if (url.pathname.startsWith("/uploads/") || url.pathname.startsWith("/data/")) { return false; } if (url.pathname.startsWith("/api/models")) { return true; } if (url.pathname.startsWith("/api/comments")) { return true; } if (url.pathname.startsWith("/api/projects")) { return true; } if (url.pathname.startsWith("/api/")) { return req.method !== "GET"; } return isHtmlNavigation(req, url); }; const ensureBimSessionForRequest = (req, res, url) => { if (!requestNeedsBimSession(req, url)) { return false; } if (getCurrentBimSession(req)) { return false; } sendAuthRequired(req, res, url); return true; }; const consumeLauncherHandoff = async (token) => { if (!INTERNAL_ACCESS_TOKEN) { throw new Error("NODEDC_INTERNAL_ACCESS_TOKEN is not configured."); } const response = await fetch(new URL("/api/internal/handoff/consume", LAUNCHER_INTERNAL_BASE_URL), { method: "POST", headers: { "Authorization": `Bearer ${INTERNAL_ACCESS_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ token, serviceSlug: BIM_SERVICE_SLUG }) }); const payload = await response.json().catch(() => null); if (!response.ok || !payload?.ok) { throw new Error(payload?.error || `Launcher handoff failed: HTTP ${response.status}`); } return payload.user; }; 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 safeAssetId = (id) => { if (typeof id !== "string") { return null; } const trimmed = id.trim(); if (!/^[-_a-zA-Z0-9]+$/.test(trimmed)) { return null; } return trimmed; }; const assetManifestPath = (assetDir) => path.join(assetDir, "asset.json"); const readAssetManifest = async (assetDir) => { const manifest = await readJSONFile(assetManifestPath(assetDir)); return manifest && typeof manifest === "object" ? manifest : null; }; const writeAssetManifest = async (assetDir, manifest) => { await writeJSONFile(assetManifestPath(assetDir), manifest); }; const calculateFileSha256 = async (filePath) => { const hash = crypto.createHash("sha256"); await pipeline(createReadStream(filePath), hash); return hash.digest("hex"); }; const fileExists = async (filePath) => { try { const stats = await fs.stat(filePath); return stats.isFile(); } catch (_) { return false; } }; const linkOrCopyUploadFile = async (sourcePath, targetPath) => { if (path.resolve(sourcePath) === path.resolve(targetPath)) { return "same"; } try { await fs.unlink(targetPath); } catch (_) { // the upload target may already have been removed by a failed retry } try { await fs.link(sourcePath, targetPath); return "hardlink"; } catch (err) { await fs.copyFile(sourcePath, targetPath); return "copy"; } }; const findReusableOriginalBySha256 = async ({sha256, sourceFormat, targetPath}) => { if (!sha256) { return null; } const targetResolved = path.resolve(targetPath); const dirs = [UPLOADS_DIR]; while (dirs.length) { const dir = dirs.pop(); let entries = []; try { entries = await fs.readdir(dir, {withFileTypes: true}); } catch (_) { continue; } for (const entry of entries) { const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) { dirs.push(entryPath); continue; } if (!entry.isFile() || !entry.name.endsWith(".beam.json")) { continue; } const manifest = await readJSONFile(entryPath); if (!manifest || typeof manifest !== "object") { continue; } if (sourceFormat && manifest.sourceFormat && manifest.sourceFormat !== sourceFormat) { continue; } const sourcePath = resolveUploadSrc(manifest.sourceSrc || manifest.downloadSrc); if (!sourcePath || path.resolve(sourcePath) === targetResolved) { continue; } if (!(await fileExists(sourcePath))) { continue; } let candidateSha256 = manifest.sha256; if (!candidateSha256) { candidateSha256 = await calculateFileSha256(sourcePath); await writeJSONFile(entryPath, {...manifest, sha256: candidateSha256}); } if (candidateSha256 !== sha256) { continue; } return { sourcePath, sourceSrc: srcFromUploadPath(sourcePath) }; } } return null; }; const nextAssetVersionNumber = (assetManifest, requestedVersion) => { const versions = Array.isArray(assetManifest?.versions) ? assetManifest.versions : []; const maxVersion = versions.reduce((max, version) => { const value = Number(version?.version); return Number.isFinite(value) ? Math.max(max, value) : max; }, 0); if (Number.isFinite(requestedVersion) && requestedVersion > maxVersion) { return requestedVersion; } return maxVersion + 1; }; const buildVersionId = (version) => `ver_${String(version).padStart(4, "0")}`; const upsertAssetVersion = async (assetDir, versionRecord) => { const existing = await readAssetManifest(assetDir); const now = nowIso(); const versions = Array.isArray(existing?.versions) ? existing.versions : []; const nextVersions = [ ...versions.filter((version) => version?.versionId !== versionRecord.versionId), versionRecord ].sort((first, second) => Number(first.version || 0) - Number(second.version || 0)); const nextManifest = { assetId: versionRecord.assetId, projectId: versionRecord.projectId, originalFilename: existing?.originalFilename || versionRecord.originalFilename, createdAt: existing?.createdAt || now, updatedAt: now, currentVersion: versionRecord.version, currentVersionId: versionRecord.versionId, versions: nextVersions }; await writeAssetManifest(assetDir, nextManifest); return nextManifest; }; const STATIC_ROOTS = [ {prefix: "/uploads/", dir: UPLOADS_DIR}, {prefix: "/data/", dir: DATA_ROOT}, {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 root = path.resolve(dir); const resolved = path.resolve(root, rel); if (!isPathInsideOrEqual(resolved, root)) { 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 isPathInsideOrEqual = (targetPath, rootPath) => { const target = path.resolve(targetPath); const root = path.resolve(rootPath); return target === root || target.startsWith(`${root}${path.sep}`); }; 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 (!isPathInsideOrEqual(resolved, UPLOADS_DIR)) { return null; } return resolved; }; const normalizeUploadSrcValue = (src) => { const resolved = resolveUploadSrc(src); return resolved ? srcFromUploadPath(resolved) : null; }; const VIEWER_TYPE_BY_EXTENSION = new Map([ [".xkt", "xkt"], [".glb", "gltf"], [".gltf", "gltf"], [".bim", "bim"], [".las", "las"], [".laz", "las"], [".obj", "obj"], [".stl", "stl"] ]); const guessViewerTypeFromSrc = (src, fallback = null) => { const pathname = (() => { try { return new URL(src, "http://localhost").pathname; } catch (_) { return src || ""; } })(); return VIEWER_TYPE_BY_EXTENSION.get(path.extname(pathname).toLowerCase()) || fallback; }; const publicUrlForUploadSrc = (req, src) => new URL(`/${src.replace(/^\/+/, "")}`, `${getRequestBaseUrl(req)}/`).toString(); const safeShareToken = (token) => { if (typeof token !== "string") { return null; } const value = token.trim(); return /^[A-Za-z0-9_-]{12,96}$/.test(value) ? value : null; }; const shareTokenFromPath = (pathname) => safeShareToken(pathname.split("/")[3] || pathname.split("/")[2]); const collectUploadSrcValues = (value, srcs = new Set()) => { if (!value) { return srcs; } if (typeof value === "string") { const normalized = normalizeUploadSrcValue(value); if (normalized) { srcs.add(normalized); } try { const parsed = new URL(value); const nestedUrl = parsed.searchParams.get("url"); const settingsSrc = parsed.searchParams.get("settingsSrc"); if (nestedUrl) collectUploadSrcValues(nestedUrl, srcs); if (settingsSrc) collectUploadSrcValues(settingsSrc, srcs); } catch (_) { // not a URL; already handled as a possible upload src } return srcs; } if (Array.isArray(value)) { for (const item of value) { collectUploadSrcValues(item, srcs); } return srcs; } if (typeof value === "object") { for (const item of Object.values(value)) { collectUploadSrcValues(item, srcs); } } return srcs; }; const getAssetDirFromSrc = (src) => { const resolved = resolveUploadSrc(src); if (!resolved) { return null; } const relativeParts = path.relative(UPLOADS_DIR, resolved).split(path.sep).filter(Boolean); if (relativeParts.length < 3) { return null; } const projectId = safeProjectId(relativeParts[0]); const assetId = safeAssetId(relativeParts[1]); if (!projectId || !assetId) { return null; } return path.join(UPLOADS_DIR, projectId, assetId); }; const resolveAssetDirFromPayload = (payload) => { const projectId = safeProjectId(payload?.projectId); const assetId = safeAssetId(payload?.assetId); if (projectId && assetId) { return path.join(UPLOADS_DIR, projectId, assetId); } const srcs = collectUploadSrcValues(payload); for (const src of srcs) { const assetDir = getAssetDirFromSrc(src); if (assetDir) { return assetDir; } } return null; }; const getVersionDirFromVersionRecord = (versionRecord, assetDir = null) => { const sourcePath = resolveUploadSrc(versionRecord?.sourceSrc || versionRecord?.downloadSrc || versionRecord?.src); if (!sourcePath) { return assetDir; } const versionDir = path.dirname(sourcePath); if (assetDir && !isPathInsideOrEqual(versionDir, assetDir)) { return assetDir; } return versionDir; }; const removeEmptyDirsUpTo = async (startDir, stopDir) => { let current = path.resolve(startDir); const stop = path.resolve(stopDir); while (isPathInsideOrEqual(current, stop) && current !== stop) { try { const entries = await fs.readdir(current); if (entries.length > 0) { return; } await fs.rmdir(current); } catch (_) { return; } current = path.dirname(current); } }; const deleteUploadSrcFiles = async (srcs, protectedSrcs = new Set(), cleanupStopDir = UPLOADS_DIR, deleteRootDir = cleanupStopDir) => { const deletedFiles = []; for (const src of srcs) { const normalized = normalizeUploadSrcValue(src); if (!normalized || protectedSrcs.has(normalized)) { continue; } const filePath = resolveUploadSrc(normalized); if (!filePath) { continue; } if (deleteRootDir && !isPathInsideOrEqual(filePath, deleteRootDir)) { continue; } try { const stats = await fs.stat(filePath); if (!stats.isFile()) { continue; } await fs.unlink(filePath); deletedFiles.push(normalized); await removeEmptyDirsUpTo(path.dirname(filePath), cleanupStopDir); } catch (_) { // idempotent cleanup: already removed files are fine } } return deletedFiles; }; const deleteUploadSrcsWithLocalManifestScope = async (requestedSrcs, protectedSrcs = new Set()) => { const deletedFiles = []; for (const src of requestedSrcs) { const sourcePath = resolveUploadSrc(src); if (!sourcePath) { continue; } const scopedSrcs = new Set([src]); const manifest = await readJSONFile(manifestPathForSource(sourcePath)); collectUploadSrcValues(manifest, scopedSrcs); const scopeDir = path.dirname(sourcePath); deletedFiles.push(...await deleteUploadSrcFiles(scopedSrcs, protectedSrcs, scopeDir, scopeDir)); } return deletedFiles; }; const readManifestForVersionRecord = async (versionRecord) => { const sourcePath = resolveUploadSrc(versionRecord?.sourceSrc || versionRecord?.downloadSrc || versionRecord?.src); if (!sourcePath) { return null; } return readJSONFile(manifestPathForSource(sourcePath)); }; const collectVersionUploadSrcs = async (versionRecord) => { const srcs = collectUploadSrcValues(versionRecord); const manifest = await readManifestForVersionRecord(versionRecord); collectUploadSrcValues(manifest, srcs); return srcs; }; const handleAuthSession = (req, res) => { const session = getCurrentBimSession(req); sendJSON(res, 200, { ok: true, authRequired: BIM_AUTH_REQUIRED, authenticated: !!session, canShare: !BIM_AUTH_REQUIRED || !!session, user: session?.user || null, loginUrl: session ? null : buildLauncherLoginUrl(req, "/") }); }; const handleLauncherHandoff = async (req, res, url) => { const token = url.searchParams.get("token") || ""; const nextPath = sanitizeReturnTo(url.searchParams.get("next_path") || url.searchParams.get("returnTo") || "/"); if (!token) { sendText(res, 400, "Missing handoff token"); return; } try { const user = await consumeLauncherHandoff(token); createBimSession(res, user); res.statusCode = 302; res.setHeader("Location", nextPath); res.end(); } catch (err) { console.error("[server] Launcher handoff failed", err); sendText(res, 403, err?.message || "Launcher handoff failed"); } }; const handleLogout = (req, res) => { const sessionId = parseCookies(req.headers.cookie || "")[BIM_SESSION_COOKIE]; if (sessionId) { bimSessions.delete(sessionId); } clearBimSessionCookie(res); res.statusCode = 302; res.setHeader("Location", "/"); res.end(); }; const handleCreateShare = async (req, res) => { const session = getCurrentBimSession(req); if (BIM_AUTH_REQUIRED && !session) { sendJSON(res, 401, {ok: false, error: "bim_auth_required"}); return; } let payload = {}; try { payload = await parseJSONBody(req); } catch (err) { sendText(res, 400, err.message || "Invalid JSON"); return; } const src = normalizeUploadSrcValue(payload?.src || payload?.url || payload?.artifactSrc); if (!src) { sendText(res, 400, "Share source must point to a BIM upload artifact."); return; } const settingsSrc = normalizeUploadSrcValue(payload?.settingsSrc || payload?.sourceSrc || payload?.originalSrc) || src; const identity = resolveAssetIdentityFromPayload({...payload, src, settingsSrc}); if (!identity) { sendText(res, 400, "Share source is not registered as a BIM model asset."); return; } const token = randomBase64Url(24); const now = nowIso(); const type = String(payload?.type || guessViewerTypeFromSrc(src, "xkt")).trim().toLowerCase(); const name = typeof payload?.name === "string" && payload.name.trim() ? payload.name.trim().slice(0, 240) : path.basename(src); const share = { token, access: "public-readonly", src, settingsSrc, type, name, assetId: identity.assetId, projectId: identity.projectId, versionId: typeof payload?.versionId === "string" ? payload.versionId : null, createdAt: now, createdBy: session?.user ? { id: session.user.id || null, email: session.user.email || null, name: session.user.name || null, subject: session.user.subject || null } : { id: "local-dev", email: null, name: "Local BIM user", subject: null } }; const shares = await readSharesIndex(); shares.push(share); await writeSharesIndex(shares); await mutateModelRegistry(async (registry) => { const asset = identity.legacy ? upsertRegistryAsset(registry, { ...identity, originalFilename: name, sourceSrc: settingsSrc, downloadSrc: src, type, status: "ready", currentVersionId: share.versionId }) : await upsertRegistryAssetFromManifest(registry, identity); upsertRegistryAsset(registry, asset || { ...identity, originalFilename: name, sourceSrc: settingsSrc, downloadSrc: src, type, status: "ready", currentVersionId: share.versionId }); upsertRegistryShareRef(registry, identity, share); }); sendJSON(res, 201, { ok: true, token, url: new URL(`/share/${token}`, `${getRequestBaseUrl(req)}/`).toString(), share }); }; const handleGetShare = async (req, res, token) => { const safeToken = safeShareToken(token); if (!safeToken) { sendText(res, 404, "Share not found"); return; } const shares = await readSharesIndex(); const share = shares.find((item) => item?.token === safeToken); if (!share) { sendText(res, 404, "Share not found"); return; } sendJSON(res, 200, { ok: true, mode: "guest", readonly: true, viewer: { src: publicUrlForUploadSrc(req, share.src), settingsSrc: publicUrlForUploadSrc(req, share.settingsSrc || share.src), type: share.type || guessViewerTypeFromSrc(share.src, "xkt"), name: share.name || path.basename(share.src), assetId: share.assetId || null, versionId: share.versionId || null }, share: { token: share.token, access: share.access, createdAt: share.createdAt, createdBy: share.createdBy ? { name: share.createdBy.name || null } : null } }); }; const sanitizeCommentText = (value, maxLength = 4000) => { if (typeof value !== "string") { return ""; } return value.trim().slice(0, maxLength); }; const normalizeCommentWorldPos = (value) => { if (!Array.isArray(value) || value.length !== 3) { return null; } const next = value.map((item) => Number(item)); return next.every((item) => Number.isFinite(item)) ? next : null; }; const sanitizeCommentAttachments = (value) => { if (!Array.isArray(value)) { return []; } return value.slice(0, 8).flatMap((item) => { if (!item || typeof item !== "object") { return []; } const dataUrl = typeof item.dataUrl === "string" ? item.dataUrl : ""; if (!/^data:[^;,]+\/?[^;,]*;base64,/i.test(dataUrl)) { return []; } const size = Number(item.size); if (Number.isFinite(size) && size > 50 * 1024 * 1024) { return []; } if (dataUrl.length > 70_000_000) { return []; } return [{ id: stringOrNull(item.id) || `att_${randomBase64Url(8)}`, name: sanitizeFilename(item.name || "file", "file"), type: stringOrNull(item.type) || "application/octet-stream", size: Number.isFinite(size) ? size : null, dataUrl }]; }); }; const sanitizeCommentSubitems = (value) => { if (!Array.isArray(value)) { return []; } return value.slice(0, 100).flatMap((item) => { if (!item || typeof item !== "object") { return []; } const kind = item.kind === "text" ? "text" : "checker"; const rawContent = kind === "text" ? (item.body ?? item.title ?? item.text ?? item.name) : (item.title ?? item.text ?? item.name ?? item.body); const content = sanitizeCommentText(rawContent, kind === "text" ? 4000 : 300); if (!content) { return []; } return [{ id: stringOrNull(item.id) || `sub_${randomBase64Url(8)}`, kind, title: content, body: kind === "text" ? content : "", done: kind === "checker" && item.done === true, createdAt: stringOrNull(item.createdAt) || nowIso() }]; }); }; const resolveCommentSource = (payload = {}) => { const identity = resolveAssetIdentityFromPayload(payload); const rawSrc = stringOrNull(payload.src) || stringOrNull(payload.url) || stringOrNull(payload.sourceSrc) || stringOrNull(payload.settingsSrc) || stringOrNull(payload.artifactSrc); const sourceSrc = normalizeUploadSrcValue(rawSrc) || rawSrc || null; if (identity) { return { sourceKey: identity.assetKey, projectId: identity.projectId, assetId: identity.assetId, sourceSrc }; } if (!sourceSrc) { return null; } return { sourceKey: `src:${shortHash(sourceSrc, 24)}`, projectId: safeProjectId(payload.projectId) || null, assetId: safeAssetId(payload.assetId) || null, sourceSrc }; }; const buildCommentMessage = (payload, user, fallbackBody = "") => { const source = typeof payload === "string" ? {body: payload} : (payload || {}); const body = sanitizeCommentText(source.body ?? source.text ?? source.message ?? fallbackBody); const attachments = sanitizeCommentAttachments(source.attachments); if (!body && !attachments.length) { return null; } const now = nowIso(); return { id: `msg_${randomBase64Url(10)}`, body, attachments, createdAt: now, updatedAt: now, createdBy: summarizeRegistryUser(user) }; }; const publicComment = (comment) => ({ id: comment.id, sourceKey: comment.sourceKey, sourceSrc: comment.sourceSrc || null, projectId: comment.projectId || null, assetId: comment.assetId || null, modelId: comment.modelId || null, objectId: comment.objectId || null, title: comment.title || "", body: comment.body || "", subitems: Array.isArray(comment.subitems) ? comment.subitems : [], worldPos: comment.worldPos || null, createdAt: comment.createdAt, updatedAt: comment.updatedAt, createdBy: comment.createdBy || null, messages: Array.isArray(comment.messages) ? comment.messages : [] }); const handleGetComments = async (req, res, url) => { const user = requireModelRegistryUser(req, res); if (!user) { return; } const source = resolveCommentSource(Object.fromEntries(url.searchParams.entries())); if (!source?.sourceKey) { sendJSON(res, 200, {ok: true, user: summarizeRegistryUser(user), comments: []}); return; } const items = await readCommentsIndex(); const comments = items .filter((item) => item?.sourceKey === source.sourceKey && !item.deletedAt) .map(publicComment) .toSorted((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || ""))); sendJSON(res, 200, { ok: true, user: summarizeRegistryUser(user), source, comments }); }; const handleCreateComment = async (req, res) => { const user = requireModelRegistryUser(req, res); if (!user) { return; } let payload = {}; try { payload = await parseJSONBody(req); } catch (err) { sendText(res, 400, err.message || "Invalid JSON"); return; } const source = resolveCommentSource(payload); const worldPos = normalizeCommentWorldPos(payload?.worldPos); const title = sanitizeCommentText(payload?.title, 180); const body = sanitizeCommentText(payload?.body ?? payload?.description, 8000); if (!source?.sourceKey) { sendText(res, 400, "Comment source is required"); return; } if (!worldPos) { sendText(res, 400, "Comment worldPos is required"); return; } if (!title && !body) { sendText(res, 400, "Comment title or body is required"); return; } const now = nowIso(); const firstMessage = buildCommentMessage({ body, attachments: payload?.attachments }, user); const comment = { id: `cmt_${randomBase64Url(14)}`, sourceKey: source.sourceKey, sourceSrc: source.sourceSrc || null, projectId: source.projectId || null, assetId: source.assetId || null, modelId: stringOrNull(payload?.modelId), objectId: stringOrNull(payload?.objectId), title: title || "Комментарий", body, subitems: sanitizeCommentSubitems(payload?.subitems), worldPos, createdAt: now, updatedAt: now, createdBy: summarizeRegistryUser(user), messages: firstMessage ? [firstMessage] : [] }; const items = await readCommentsIndex(); items.unshift(comment); await writeCommentsIndex(items); sendJSON(res, 201, {ok: true, comment: publicComment(comment)}); }; const handlePatchComment = async (req, res, commentId) => { const user = requireModelRegistryUser(req, res); if (!user) { return; } const safeId = stringOrNull(commentId); if (!safeId) { sendText(res, 400, "Invalid comment id"); return; } let payload = {}; try { payload = await parseJSONBody(req); } catch (err) { sendText(res, 400, err.message || "Invalid JSON"); return; } const items = await readCommentsIndex(); const index = items.findIndex((item) => item?.id === safeId && !item.deletedAt); if (index < 0) { sendText(res, 404, "Comment not found"); return; } const comment = items[index]; const isOwner = comment?.createdBy?.id && comment.createdBy.id === user.id; const now = nowIso(); if (payload?.delete === true) { if (!isOwner) { sendJSON(res, 403, {ok: false, error: "comment_owner_required"}); return; } comment.deletedAt = now; comment.updatedAt = now; await writeCommentsIndex(items); sendJSON(res, 200, {ok: true, deleted: true, id: safeId}); return; } const hasTitle = Object.prototype.hasOwnProperty.call(payload, "title"); const hasBody = Object.prototype.hasOwnProperty.call(payload, "body"); const hasSubitems = Object.prototype.hasOwnProperty.call(payload, "subitems"); const nextTitle = hasTitle ? sanitizeCommentText(payload.title, 180) || comment.title || "Комментарий" : comment.title; const nextBody = hasBody ? sanitizeCommentText(payload.body, 8000) : comment.body; const nextSubitems = hasSubitems ? sanitizeCommentSubitems(payload.subitems) : (Array.isArray(comment.subitems) ? comment.subitems : []); const titleChanged = hasTitle && nextTitle !== comment.title; const bodyChanged = hasBody && nextBody !== comment.body; const subitemsChanged = hasSubitems && JSON.stringify(nextSubitems) !== JSON.stringify(comment.subitems || []); if ((titleChanged || bodyChanged || subitemsChanged) && !isOwner) { sendJSON(res, 403, {ok: false, error: "comment_owner_required"}); return; } if (titleChanged) { comment.title = nextTitle; } if (bodyChanged) { comment.body = nextBody; } if (subitemsChanged) { comment.subitems = nextSubitems; } const nextMessage = buildCommentMessage(payload?.message || null, user); if (nextMessage) { comment.messages = Array.isArray(comment.messages) ? comment.messages : []; comment.messages.push(nextMessage); } comment.updatedAt = now; items[index] = comment; await writeCommentsIndex(items); sendJSON(res, 200, {ok: true, comment: publicComment(comment)}); }; const requireModelRegistryUser = (req, res) => { const user = getRequestRegistryUser(req); if (!user) { sendJSON(res, 401, {ok: false, error: "bim_auth_required"}); return null; } return user; }; const handleGetModels = async (req, res) => { const user = requireModelRegistryUser(req, res); if (!user) { return; } const registry = await readModelRegistry(); const assetsByKey = new Map(registry.assets.map((asset) => [asset?.assetKey, asset]).filter(([key]) => !!key)); const models = registry.refs .filter((ref) => ref?.user?.id === user.id && !ref.deletedAt) .map((ref) => { const asset = assetsByKey.get(ref.assetKey) || { assetKey: ref.assetKey, assetId: ref.assetId, projectId: ref.projectId, sourceSrc: ref.settingsSrc || ref.src || null, downloadSrc: ref.src || ref.settingsSrc || null, originalFilename: ref.src ? path.basename(ref.src) : ref.assetId, type: guessViewerTypeFromSrc(ref.src || ref.settingsSrc || "", null), status: "unknown" }; return buildModelListItem(asset, ref, registry); }) .toSorted((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || ""))); sendJSON(res, 200, { ok: true, user: summarizeRegistryUser(user), models }); }; const handleCreateModelRef = async (req, res) => { const user = requireModelRegistryUser(req, res); if (!user) { return; } let payload = {}; try { payload = await parseJSONBody(req); } catch (err) { sendText(res, 400, err.message || "Invalid JSON"); return; } const src = normalizeUploadSrcValue(payload?.src || payload?.url || payload?.artifactSrc); const settingsSrc = normalizeUploadSrcValue(payload?.settingsSrc || payload?.sourceSrc || payload?.originalSrc) || src; const identity = resolveAssetIdentityFromPayload({...payload, src, settingsSrc}); if (!identity) { sendText(res, 400, "Model ref must point to a BIM upload asset."); return; } const result = await mutateModelRegistry(async (registry) => { const name = stringOrNull(payload?.name) || (src ? path.basename(src) : identity.assetId); const asset = identity.legacy ? upsertRegistryAsset(registry, { ...identity, originalFilename: name, sourceSrc: settingsSrc, downloadSrc: src, type: stringOrNull(payload?.type) || guessViewerTypeFromSrc(src || settingsSrc || "", "file"), status: "ready", currentVersionId: stringOrNull(payload?.versionId) }) : await upsertRegistryAssetFromManifest(registry, identity); const normalizedAsset = asset || upsertRegistryAsset(registry, { ...identity, originalFilename: name, sourceSrc: settingsSrc, downloadSrc: src, type: stringOrNull(payload?.type) || guessViewerTypeFromSrc(src || settingsSrc || "", "file"), status: "ready", currentVersionId: stringOrNull(payload?.versionId) }); const ref = upsertRegistryUserRef(registry, identity, user, { kind: payload?.kind === "owner" ? "owner" : "saved", source: stringOrNull(payload?.source) || "manual", versionId: stringOrNull(payload?.versionId), src, settingsSrc, touch: true }); return {asset: normalizedAsset, ref}; }); sendJSON(res, 201, { ok: true, model: buildModelListItem(result.asset, result.ref, {shares: [], refs: []}) }); }; const handleDeleteModelRef = async (req, res) => { const user = requireModelRegistryUser(req, res); if (!user) { return; } let payload = {}; try { payload = await parseJSONBody(req); } catch (err) { sendText(res, 400, err.message || "Invalid JSON"); return; } const identity = resolveAssetIdentityFromPayload(payload); if (!identity) { sendText(res, 400, "Model ref must identify a BIM upload asset."); return; } const decision = await mutateModelRegistry(async (registry) => { const removedRefs = markRegistryUserRefsDeleted(registry, identity, user); const activeRefs = getActiveRegistryRefs(registry, identity); const summary = summarizeActiveRegistryRefs(activeRefs); return { canRemovePhysicalAsset: summary.total === 0, removedUserRefs: removedRefs.length, activeRefs: summary }; }); if (!decision.canRemovePhysicalAsset) { sendJSON(res, 200, { ok: true, deleted: true, removedAsset: false, removedUserRefs: decision.removedUserRefs, retainedByRefs: true, activeRefs: decision.activeRefs }); return; } const assetDir = path.join(UPLOADS_DIR, identity.projectId, identity.assetId); const deletedFiles = []; if (!identity.legacy && isPathInsideOrEqual(assetDir, UPLOADS_DIR)) { try { const stats = await fs.stat(assetDir); if (stats.isDirectory()) { await fs.rm(assetDir, {recursive: true, force: true}); await removeEmptyDirsUpTo(path.dirname(assetDir), UPLOADS_DIR); } } catch (_) { // idempotent cleanup } } else { deletedFiles.push(...await deleteUploadSrcsWithLocalManifestScope(collectUploadSrcValues(payload))); } await mutateModelRegistry(async (registry) => { markRegistryAssetPhysicallyDeleted(registry, identity); }); sendJSON(res, 200, { ok: true, deleted: true, deletedFiles, removedAsset: !identity.legacy, removedUserRefs: decision.removedUserRefs, retainedByRefs: false }); }; 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 task/work item const projectId = safeProjectId(url.searchParams.get("projectId")); const uploadId = projectId || (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)); const requestedAssetId = safeAssetId(url.searchParams.get("assetId")); const assetId = requestedAssetId || `model_${crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)}`; const assetDir = path.join(UPLOADS_DIR, uploadId, assetId); const existingAssetManifest = await readAssetManifest(assetDir); const requestedVersion = Number.parseInt(url.searchParams.get("version") || "", 10); const version = nextAssetVersionNumber(existingAssetManifest, requestedVersion); const versionId = buildVersionId(version); const versionDir = path.join(assetDir, versionId); await fs.mkdir(versionDir, {recursive: true}); const targetPath = path.join(versionDir, 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 uploadedAt = nowIso(); const sha256 = await calculateFileSha256(targetPath); const src = path.join("uploads", uploadId, assetId, versionId, safeName).replace(/\\/g, "/"); const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(safeName).toLowerCase()); const reusableOriginal = await findReusableOriginalBySha256({sha256, sourceFormat, targetPath}); const sourceDedup = reusableOriginal ? { sourceDedupMode: await linkOrCopyUploadFile(reusableOriginal.sourcePath, targetPath), sourceDedupedFromSourceSrc: reusableOriginal.sourceSrc } : null; const fileStats = await fs.stat(targetPath); const versionRecord = { assetId, projectId: uploadId, version, versionId, originalFilename: rawName, storedFilename: safeName, size: fileStats.size, sha256, sourceSrc: src, downloadSrc: src, sourceFormat: sourceFormat || path.extname(safeName).replace(/^\./, "").toLowerCase() || "file", status: sourceFormat ? "conversion_required" : "ready", targetFormat: sourceFormat ? "xkt" : null, uploadedAt, ...(sourceDedup || {}) }; if (sourceFormat) { const conversion = { ...versionRecord, componentTreeRequired: true, message: "Оригинальный STEP загружен. Просмотр появится после подготовки модели и дерева компонентов.", sourceFormat, status: "conversion_required", targetFormat: "xkt", updatedAt: uploadedAt }; await writeJSONFile(manifestPathForSource(targetPath), conversion); await upsertAssetVersion(assetDir, {...versionRecord, message: conversion.message}); await mutateModelRegistry(async (registry) => { const asset = upsertRegistryAsset(registry, registryAssetFromVersionRecord({...versionRecord, message: conversion.message})); upsertRegistryUserRef(registry, asset, getRequestRegistryUser(req), { kind: "owner", source: "upload", versionId, src, settingsSrc: src }); }); sendJSON(res, 201, { assetId, conversion, originalFilename: rawName, sha256, src, uploadedAt, version, versionId }); return; } await writeJSONFile(manifestPathForSource(targetPath), { ...versionRecord, message: "Model is ready.", updatedAt: uploadedAt }); await upsertAssetVersion(assetDir, versionRecord); await mutateModelRegistry(async (registry) => { const asset = upsertRegistryAsset(registry, registryAssetFromVersionRecord(versionRecord)); upsertRegistryUserRef(registry, asset, getRequestRegistryUser(req), { kind: "owner", source: "upload", versionId, src, settingsSrc: src }); }); sendJSON(res, 201, { assetId, originalFilename: rawName, sha256, src, uploadedAt, version, versionId }); }; const handleDeleteUploadAsset = async (req, res) => { let payload = {}; try { payload = await parseJSONBody(req); } catch (err) { sendText(res, 400, err.message || "Invalid JSON"); return; } const deletedFiles = []; const assetDir = resolveAssetDirFromPayload(payload); const identity = resolveAssetIdentityFromPayload(payload) || getAssetIdentityFromAssetDir(assetDir); const user = getRequestRegistryUser(req); let registryDecision = null; if (identity) { registryDecision = await mutateModelRegistry(async (registry) => { if (identity.legacy) { const srcs = collectUploadSrcValues(payload); const firstSrc = [...srcs][0] || null; upsertRegistryAsset(registry, { ...identity, originalFilename: firstSrc ? path.basename(firstSrc) : null, sourceSrc: firstSrc, downloadSrc: firstSrc, type: guessViewerTypeFromSrc(firstSrc || "", "file"), status: "ready" }); } else { await upsertRegistryAssetFromManifest(registry, identity); } const removedRefs = markRegistryUserRefsDeleted(registry, identity, user); const activeRefs = getActiveRegistryRefs(registry, identity); const summary = summarizeActiveRegistryRefs(activeRefs); return { canRemovePhysicalAsset: summary.total === 0, removedUserRefs: removedRefs.length, activeRefs: summary }; }); if (!registryDecision.canRemovePhysicalAsset) { sendJSON(res, 200, { deleted: true, deletedFiles, removedAsset: false, removedUserRefs: registryDecision.removedUserRefs, retainedByRefs: true, activeRefs: registryDecision.activeRefs }); return; } } if (assetDir) { try { const stats = await fs.stat(assetDir); if (stats.isDirectory()) { await fs.rm(assetDir, {recursive: true, force: true}); await removeEmptyDirsUpTo(path.dirname(assetDir), UPLOADS_DIR); } } catch (_) { // idempotent delete } if (identity) { await mutateModelRegistry(async (registry) => { markRegistryAssetPhysicallyDeleted(registry, identity); }); } sendJSON(res, 200, { deleted: true, deletedFiles, removedAsset: true, removedUserRefs: registryDecision?.removedUserRefs || 0, retainedByRefs: false }); return; } const fallbackSrcs = collectUploadSrcValues(payload); deletedFiles.push(...await deleteUploadSrcsWithLocalManifestScope(fallbackSrcs)); if (identity) { await mutateModelRegistry(async (registry) => { markRegistryAssetPhysicallyDeleted(registry, identity); }); } sendJSON(res, 200, { deleted: true, deletedFiles, removedAsset: false, removedUserRefs: registryDecision?.removedUserRefs || 0, retainedByRefs: false }); }; const handleDeleteUploadVersion = async (req, res) => { let payload = {}; try { payload = await parseJSONBody(req); } catch (err) { sendText(res, 400, err.message || "Invalid JSON"); return; } const assetDir = resolveAssetDirFromPayload(payload); const identity = resolveAssetIdentityFromPayload(payload) || getAssetIdentityFromAssetDir(assetDir); const user = getRequestRegistryUser(req); const requestedVersionId = typeof payload?.versionId === "string" ? payload.versionId : null; const requestedVersion = Number.parseInt(String(payload?.version || ""), 10); const requestedSrcs = collectUploadSrcValues(payload); if (!assetDir) { if (identity) { const decision = await mutateModelRegistry(async (registry) => { const activeRefs = getActiveRegistryRefsForVersion(registry, identity, {versionId: requestedVersionId}, requestedSrcs); return summarizeActiveRegistryRefs(activeRefs); }); if (decision.total > 0) { sendJSON(res, 200, { deleted: true, deletedFiles: [], removedAsset: false, retainedByRefs: true, activeRefs: decision }); return; } } const deletedFiles = await deleteUploadSrcsWithLocalManifestScope(requestedSrcs); if (identity) { await mutateModelRegistry(async (registry) => { removeRegistryVersion(registry, identity, {versionId: requestedVersionId}); }); } sendJSON(res, 200, { deleted: true, deletedFiles, removedAsset: false }); return; } const assetManifest = await readAssetManifest(assetDir); if (!assetManifest) { const expandedSrcs = new Set(requestedSrcs); for (const src of requestedSrcs) { const sourcePath = resolveUploadSrc(src); if (!sourcePath) continue; const manifest = await readJSONFile(manifestPathForSource(sourcePath)); collectUploadSrcValues(manifest, expandedSrcs); } if (identity) { const decision = await mutateModelRegistry(async (registry) => { const activeRefs = getActiveRegistryRefsForVersion(registry, identity, {versionId: requestedVersionId}, expandedSrcs); return summarizeActiveRegistryRefs(activeRefs); }); if (decision.total > 0) { sendJSON(res, 200, { deleted: true, deletedFiles: [], removedAsset: false, retainedByRefs: true, activeRefs: decision }); return; } } const deletedFiles = await deleteUploadSrcFiles(expandedSrcs, new Set(), assetDir, assetDir); if (identity) { await mutateModelRegistry(async (registry) => { removeRegistryVersion(registry, identity, {versionId: requestedVersionId}); }); } sendJSON(res, 200, { deleted: true, deletedFiles, removedAsset: false }); return; } const versions = Array.isArray(assetManifest.versions) ? assetManifest.versions : []; const normalizedRequestedSrcs = new Set([...requestedSrcs].map(normalizeUploadSrcValue).filter(Boolean)); const versionToDelete = versions.find((version) => { if (requestedVersionId && version?.versionId === requestedVersionId) { return true; } if (Number.isFinite(requestedVersion) && Number(version?.version) === requestedVersion) { return true; } const versionSrcs = collectUploadSrcValues(version); return [...versionSrcs].some((src) => normalizedRequestedSrcs.has(src)); }); if (!versionToDelete) { sendJSON(res, 200, { deleted: true, deletedFiles: [], removedAsset: false, versionMissing: true }); return; } const remainingVersions = versions.filter((version) => version !== versionToDelete); if (remainingVersions.length === 0) { let registryDecision = null; if (identity) { registryDecision = await mutateModelRegistry(async (registry) => { await upsertRegistryAssetFromManifest(registry, identity); const removedRefs = markRegistryUserRefsDeleted(registry, identity, user); const activeRefs = getActiveRegistryRefs(registry, identity); const summary = summarizeActiveRegistryRefs(activeRefs); return { canRemovePhysicalAsset: summary.total === 0, removedUserRefs: removedRefs.length, activeRefs: summary }; }); if (!registryDecision.canRemovePhysicalAsset) { sendJSON(res, 200, { deleted: true, deletedFiles: [], removedAsset: false, removedUserRefs: registryDecision.removedUserRefs, retainedByRefs: true, activeRefs: registryDecision.activeRefs, versionId: versionToDelete.versionId }); return; } } try { await fs.rm(assetDir, {recursive: true, force: true}); await removeEmptyDirsUpTo(path.dirname(assetDir), UPLOADS_DIR); } catch (_) { // idempotent delete } if (identity) { await mutateModelRegistry(async (registry) => { markRegistryAssetPhysicallyDeleted(registry, identity); }); } sendJSON(res, 200, { deleted: true, deletedFiles: [], removedAsset: true, removedUserRefs: registryDecision?.removedUserRefs || 0, retainedByRefs: false, versionId: versionToDelete.versionId }); return; } const targetSrcs = await collectVersionUploadSrcs(versionToDelete); if (identity) { const registryDecision = await mutateModelRegistry(async (registry) => { await upsertRegistryAssetFromManifest(registry, identity); const activeRefs = getActiveRegistryRefsForVersion(registry, identity, versionToDelete, targetSrcs); const currentUserId = normalizeRegistryUser(user)?.id || null; const blockingUserRefs = activeRefs.userRefs.filter((ref) => !currentUserId || ref?.user?.id !== currentUserId); return summarizeActiveRegistryRefs({ userRefs: blockingUserRefs, shareRefs: activeRefs.shareRefs }); }); if (registryDecision.total > 0) { sendJSON(res, 200, { deleted: true, deletedFiles: [], removedAsset: false, retainedByRefs: true, activeRefs: registryDecision, versionId: versionToDelete.versionId }); return; } } const versionDir = getVersionDirFromVersionRecord(versionToDelete, assetDir); const protectedSrcs = new Set(); for (const version of remainingVersions) { const versionSrcs = await collectVersionUploadSrcs(version); for (const src of versionSrcs) { protectedSrcs.add(src); } } const deletedFiles = await deleteUploadSrcFiles(targetSrcs, protectedSrcs, assetDir, versionDir); if (versionDir && versionToDelete?.versionId && path.basename(versionDir) === versionToDelete.versionId && isPathInsideOrEqual(versionDir, assetDir)) { try { await fs.rm(versionDir, {recursive: true, force: true}); } catch (_) { // idempotent cleanup } } const deletedCurrent = assetManifest.currentVersionId ? assetManifest.currentVersionId === versionToDelete.versionId : Number(assetManifest.currentVersion) === Number(versionToDelete.version); const nextCurrent = deletedCurrent ? remainingVersions.toSorted((first, second) => Number(second.version || 0) - Number(first.version || 0))[0] : remainingVersions.find((version) => version.versionId === assetManifest.currentVersionId) || remainingVersions[remainingVersions.length - 1]; const nextManifest = { ...assetManifest, updatedAt: nowIso(), currentVersion: nextCurrent?.version, currentVersionId: nextCurrent?.versionId, versions: remainingVersions }; await writeAssetManifest(assetDir, nextManifest); if (identity) { await mutateModelRegistry(async (registry) => { removeRegistryVersion(registry, identity, versionToDelete); const currentUser = normalizeRegistryUser(user); if (currentUser) { for (const ref of registry.refs) { if (ref?.assetKey !== identity.assetKey || ref?.user?.id !== currentUser.id || ref.deletedAt) { continue; } if (ref.versionId === versionToDelete.versionId) { ref.versionId = nextCurrent?.versionId || null; ref.src = normalizeUploadSrcValue(nextCurrent?.downloadSrc || nextCurrent?.sourceSrc || nextCurrent?.src) || ref.src || null; ref.settingsSrc = normalizeUploadSrcValue(nextCurrent?.sourceSrc || nextCurrent?.downloadSrc || nextCurrent?.src) || ref.settingsSrc || null; ref.updatedAt = nowIso(); } } } }); } sendJSON(res, 200, { deleted: true, deletedFiles, removedAsset: false, versionId: versionToDelete.versionId }); }; 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: "Оригинальный STEP загружен. Просмотр появится после подготовки модели и дерева компонентов.", 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 cleanupProjectUploadsForUser = async (req, projectId) => { const safeId = safeProjectId(projectId); if (!safeId) { return {removedAssets: 0, retainedAssets: 0, deletedFiles: []}; } const uploadDir = path.join(UPLOADS_DIR, safeId); const user = getRequestRegistryUser(req); const result = { removedAssets: 0, retainedAssets: 0, deletedFiles: [] }; let entries = []; try { entries = await fs.readdir(uploadDir, {withFileTypes: true}); } catch (_) { return result; } for (const entry of entries) { const entryPath = path.join(uploadDir, entry.name); const identity = entry.isDirectory() ? normalizeAssetIdentity({projectId: safeId, assetId: entry.name}) : getAssetIdentityFromUploadPath(entryPath); if (!identity) { continue; } const decision = await mutateModelRegistry(async (registry) => { if (entry.isDirectory() && !identity.legacy) { await upsertRegistryAssetFromManifest(registry, identity); } else { const src = srcFromUploadPath(entryPath); upsertRegistryAsset(registry, { ...identity, originalFilename: entry.name, sourceSrc: src, downloadSrc: src, type: guessViewerTypeFromSrc(src, "file"), status: "ready" }); } markRegistryUserRefsDeleted(registry, identity, user); return summarizeActiveRegistryRefs(getActiveRegistryRefs(registry, identity)); }); if (decision.total > 0) { result.retainedAssets += 1; continue; } if (entry.isDirectory()) { try { await fs.rm(entryPath, {recursive: true, force: true}); result.removedAssets += 1; } catch (_) { // idempotent cleanup } } else if (entry.isFile()) { const src = srcFromUploadPath(entryPath); result.deletedFiles.push(...await deleteUploadSrcsWithLocalManifestScope(new Set([src]))); } await mutateModelRegistry(async (registry) => { markRegistryAssetPhysicallyDeleted(registry, identity); }); } await removeEmptyDirsUpTo(uploadDir, UPLOADS_DIR); return result; }; const handleDeleteProject = 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; } try { await fs.unlink(projectPath); } catch (err) { console.error("[server] failed to delete project file", err); } const uploadCleanup = await cleanupProjectUploadsForUser(req, safeId); const items = await readIndex(); const filtered = items.filter((item) => item.id !== safeId); await writeIndex(filtered); sendJSON(res, 200, {deleted: true, id: safeId, uploadCleanup}); }; const requestHandler = async (req, res) => { const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); setCors(req, res); 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 (req.method === "GET" && url.pathname === "/api/auth/session") { return handleAuthSession(req, res); } if (req.method === "GET" && url.pathname === "/auth/nodedc/handoff") { return handleLauncherHandoff(req, res, url); } if (req.method === "GET" && url.pathname === "/auth/logout") { return handleLogout(req, res); } if (req.method === "GET" && /^\/api\/shares\/[^/]+\/?$/.test(url.pathname)) { return handleGetShare(req, res, shareTokenFromPath(url.pathname)); } if (ensureBimSessionForRequest(req, res, url)) { return; } if (req.method === "POST" && url.pathname === "/api/shares") { return handleCreateShare(req, res); } if (url.pathname.startsWith("/api/comments")) { if (req.method === "GET" && url.pathname === "/api/comments") { return handleGetComments(req, res, url); } if (req.method === "POST" && url.pathname === "/api/comments") { return handleCreateComment(req, res); } if (req.method === "PATCH" && /^\/api\/comments\/[^/]+\/?$/.test(url.pathname)) { return handlePatchComment(req, res, url.pathname.split("/")[3]); } sendText(res, 405, "Method Not Allowed"); return; } if (url.pathname.startsWith("/api/models")) { if (req.method === "GET" && url.pathname === "/api/models") { return handleGetModels(req, res); } if (req.method === "POST" && url.pathname === "/api/models/refs") { return handleCreateModelRef(req, res); } if (req.method === "DELETE" && url.pathname === "/api/models/refs") { return handleDeleteModelRef(req, res); } sendText(res, 405, "Method Not Allowed"); 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(req, 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 === "DELETE" && url.pathname === "/api/uploads/asset") { return handleDeleteUploadAsset(req, res); } if (req.method === "DELETE" && url.pathname === "/api/uploads/version") { return handleDeleteUploadVersion(req, res); } 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); });