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_HANDOFF_COOKIE = process.env.NODEDC_BIM_HANDOFF_COOKIE || "nodedc_bim_handoff"; const BIM_SESSION_TTL_MS = Number(process.env.NODEDC_BIM_SESSION_TTL_MS || 12 * 60 * 60 * 1000); const BIM_HANDOFF_TTL_SECONDS = Number(process.env.NODEDC_BIM_HANDOFF_TTL_SECONDS || 60); const BIM_LOGIN_SYNC_TTL_MS = Number(process.env.NODEDC_BIM_LOGIN_SYNC_TTL_MS || 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 SHARE_AUTH_GUEST_PARAM = "ndc_bim_guest"; const CORS_ALLOWED_ORIGINS = String(process.env.NODEDC_BIM_ALLOWED_ORIGINS || "") .split(",") .map((origin) => origin.trim()) .filter(Boolean); const bimSessions = new Map(); let latestBimLoginSyncEvent = null; 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].sort((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, versions: Array.isArray(asset.versions) ? asset.versions : [], 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 markBimLoginSyncEvent = () => { const ttlMs = Number.isFinite(BIM_LOGIN_SYNC_TTL_MS) ? Math.max(1000, BIM_LOGIN_SYNC_TTL_MS) : 60 * 1000; latestBimLoginSyncEvent = { id: randomBase64Url(16), createdAt: nowIso(), expiresAt: Date.now() + ttlMs }; return latestBimLoginSyncEvent; }; const getActiveBimLoginSyncEvent = () => { if (!latestBimLoginSyncEvent) { return null; } if (latestBimLoginSyncEvent.expiresAt <= Date.now()) { latestBimLoginSyncEvent = null; return null; } return latestBimLoginSyncEvent; }; 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 appendSetCookieHeader = (res, cookie) => { const existing = res.getHeader("Set-Cookie"); if (!existing) { res.setHeader("Set-Cookie", cookie); return; } res.setHeader("Set-Cookie", Array.isArray(existing) ? [...existing, cookie] : [existing, cookie]); }; const clearBimSessionCookie = (res) => { appendSetCookieHeader(res, buildCookieHeader(BIM_SESSION_COOKIE, "", 0)); }; const setBimHandoffCookie = (res) => { appendSetCookieHeader(res, buildCookieHeader(BIM_HANDOFF_COOKIE, randomBase64Url(16), BIM_HANDOFF_TTL_SECONDS)); }; const clearBimHandoffCookie = (res) => { appendSetCookieHeader(res, buildCookieHeader(BIM_HANDOFF_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, {launcherSessionId = null} = {}) => { pruneExpiredBimSessions(); const id = randomBase64Url(32); const session = { id, user, launcherSessionId: typeof launcherSessionId === "string" && launcherSessionId ? launcherSessionId : null, createdAt: nowIso(), expiresAt: Date.now() + BIM_SESSION_TTL_MS }; bimSessions.set(id, session); appendSetCookieHeader(res, 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 destroyCurrentBimSession = (req, res) => { const sessionId = parseCookies(req.headers.cookie || "")[BIM_SESSION_COOKIE]; if (sessionId) { bimSessions.delete(sessionId); } clearBimSessionCookie(res); }; const validateBimSessionWithLauncher = async (session) => { if (!BIM_AUTH_REQUIRED) { return {active: true, user: session?.user || null}; } if (!INTERNAL_ACCESS_TOKEN || !session?.launcherSessionId) { return {active: false, error: "launcher_session_missing"}; } const response = await fetch(new URL("/api/internal/session/validate", LAUNCHER_INTERNAL_BASE_URL), { method: "POST", headers: { "Authorization": `Bearer ${INTERNAL_ACCESS_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ serviceSlug: BIM_SERVICE_SLUG, launcherSessionId: session.launcherSessionId }) }); const payload = await response.json().catch(() => null); if (!response.ok || !payload?.ok || payload.active !== true) { return {active: false, error: payload?.error || `launcher_session_invalid_${response.status}`}; } return {active: true, user: payload.user || session.user}; }; const getValidatedBimSession = async (req, res = null) => { const session = getCurrentBimSession(req); if (!session) { return null; } try { const validation = await validateBimSessionWithLauncher(session); if (validation.active) { if (validation.user) { session.user = validation.user; } return session; } bimSessions.delete(session.id); if (res) { clearBimSessionCookie(res); clearBimHandoffCookie(res); } return null; } catch (err) { console.warn("[server] Launcher session validation failed", err?.message || err); bimSessions.delete(session.id); if (res) { clearBimSessionCookie(res); clearBimHandoffCookie(res); } return null; } }; const consumeBimHandoffCookie = (req, res) => { const token = parseCookies(req.headers.cookie || "")[BIM_HANDOFF_COOKIE]; if (!token) { return false; } req.__bimHandoffNavigation = true; clearBimHandoffCookie(res); return true; }; 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 buildLauncherLaunchUrl = (nextPath = "/") => { const launchPath = `/api/services/${encodeURIComponent(BIM_SERVICE_SLUG)}/launch`; const launchUrl = new URL(launchPath, LAUNCHER_BASE_URL); launchUrl.searchParams.set("returnTo", sanitizeReturnTo(nextPath)); return launchUrl.toString(); }; const buildLauncherOptionalLaunchUrl = (nextPath = "/", fallbackPath = nextPath) => { const launchPath = `/api/services/${encodeURIComponent(BIM_SERVICE_SLUG)}/optional-launch`; const launchUrl = new URL(launchPath, LAUNCHER_BASE_URL); launchUrl.searchParams.set("returnTo", sanitizeReturnTo(nextPath)); launchUrl.searchParams.set("fallbackTo", sanitizeReturnTo(fallbackPath, nextPath)); return launchUrl.toString(); }; const buildLauncherLoginUrl = (req, nextPath = "/") => { const loginUrl = new URL("/auth/login", LAUNCHER_BASE_URL); const launchUrl = new URL(buildLauncherLaunchUrl(nextPath)); loginUrl.searchParams.set("returnTo", `${launchUrl.pathname}${launchUrl.search}`); return loginUrl.toString(); }; const buildSharePromotionUrls = (token) => { const safeToken = safeShareToken(token); if (!safeToken) { return {promoteUrl: null, silentPromoteUrl: null, optionalPromoteUrl: null}; } const sharePath = `/share/${encodeURIComponent(safeToken)}`; const guestPath = `${sharePath}?${SHARE_AUTH_GUEST_PARAM}=1`; const bridgePath = `/auth/share-promoted?next=${encodeURIComponent(sharePath)}`; const guestBridgePath = `/auth/share-promoted?next=${encodeURIComponent(sharePath)}&status=guest`; return { promoteUrl: buildLauncherLaunchUrl(sharePath), silentPromoteUrl: buildLauncherOptionalLaunchUrl(bridgePath, guestBridgePath), optionalPromoteUrl: buildLauncherOptionalLaunchUrl(sharePath, guestPath) }; }; const shouldPromoteShareNavigation = (req, url) => { if (!BIM_AUTH_REQUIRED || !INTERNAL_ACCESS_TOKEN || !isPublicSharePath(url)) { return false; } if (req.method !== "GET" && req.method !== "HEAD") { return false; } if (url.searchParams.get(SHARE_AUTH_GUEST_PARAM) === "1") { return false; } if (req.__bimHandoffNavigation) { return false; } const accept = typeof req.headers.accept === "string" ? req.headers.accept : ""; return accept.includes("text/html") || accept.includes("*/*") || !accept; }; const redirectShareNavigationToPromotion = (res, token) => { const urls = buildSharePromotionUrls(token); if (!urls.optionalPromoteUrl) { return false; } res.statusCode = 302; res.setHeader("Location", urls.optionalPromoteUrl); res.setHeader("Cache-Control", "no-store"); res.end(); return true; }; const currentRequestReturnPath = (url) => sanitizeReturnTo(`${url.pathname}${url.search || ""}`); const handleLauncherOwnedNavigation = (req, res, url) => { if (!BIM_AUTH_REQUIRED || !INTERNAL_ACCESS_TOKEN || !isHtmlNavigation(req, url)) { return false; } if (isPublicSharePath(url) && url.searchParams.get(SHARE_AUTH_GUEST_PARAM) === "1") { destroyCurrentBimSession(req, res); clearBimHandoffCookie(res); return false; } if (consumeBimHandoffCookie(req, res)) { return false; } if (isPublicSharePath(url)) { return redirectShareNavigationToPromotion(res, shareTokenFromPath(url.pathname)); } if (getCurrentBimSession(req)) { res.statusCode = 302; res.setHeader("Location", buildLauncherLaunchUrl(currentRequestReturnPath(url))); res.setHeader("Cache-Control", "no-store"); res.end(); return true; } return false; }; 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 = async (req, res, url) => { if (!requestNeedsBimSession(req, url)) { return false; } if (await getValidatedBimSession(req, res)) { 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 { user: payload.user, launcherSessionId: typeof payload.launcherSessionId === "string" ? payload.launcherSessionId : null }; }; 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 inferVersionInfoFromUploadSrc = (src) => { const normalized = normalizeUploadSrcValue(src); const match = normalized ? normalized.match(/(?:^|\/)(ver_(\d+))(?:\/|$)/) : null; const version = match ? Number.parseInt(match[2], 10) : null; return { version: Number.isFinite(version) ? version : null, versionId: match ? match[1] : null }; }; const hydrateAssetVersionRecord = async (versionRecord) => { const sourcePath = resolveUploadSrc(versionRecord?.sourceSrc || versionRecord?.downloadSrc || versionRecord?.src); const manifest = sourcePath ? await readJSONFile(manifestPathForSource(sourcePath)) : null; const inferred = inferVersionInfoFromUploadSrc(versionRecord?.sourceSrc || versionRecord?.downloadSrc || versionRecord?.src || manifest?.sourceSrc); const rawVersion = versionRecord?.version ?? manifest?.version ?? inferred.version; const version = Number(rawVersion); return { ...(versionRecord || {}), ...(manifest && typeof manifest === "object" ? manifest : {}), assetId: versionRecord?.assetId || manifest?.assetId || null, projectId: versionRecord?.projectId || manifest?.projectId || null, version: Number.isFinite(version) && version > 0 ? version : null, versionId: stringOrNull(versionRecord?.versionId || manifest?.versionId || inferred.versionId), originalFilename: stringOrNull(versionRecord?.originalFilename || manifest?.originalFilename) || (sourcePath ? path.basename(sourcePath) : null), sourceSrc: normalizeUploadSrcValue(manifest?.sourceSrc || versionRecord?.sourceSrc || versionRecord?.src), downloadSrc: normalizeUploadSrcValue(versionRecord?.downloadSrc || manifest?.downloadSrc || manifest?.sourceSrc || versionRecord?.sourceSrc || versionRecord?.src), artifactSrc: normalizeUploadSrcValue(manifest?.artifactSrc || versionRecord?.artifactSrc), metadataSrc: normalizeUploadSrcValue(manifest?.metadataSrc || versionRecord?.metadataSrc) }; }; const mergeHydratedVersionRecords = (previous, next) => { if (!previous) { return next; } return { ...previous, ...next, artifactSrc: next.artifactSrc || previous.artifactSrc || null, downloadSrc: next.downloadSrc || previous.downloadSrc || null, metadataSrc: next.metadataSrc || previous.metadataSrc || null, originalFilename: next.originalFilename || previous.originalFilename || null, sourceSrc: next.sourceSrc || previous.sourceSrc || null, status: previous.status === "ready" || next.status === "ready" ? "ready" : (next.status || previous.status || null), version: next.version || previous.version || null, versionId: next.versionId || previous.versionId || null }; }; const publicModelVersionRecord = (req, versionRecord) => { const sourceSrc = normalizeUploadSrcValue(versionRecord?.sourceSrc || versionRecord?.src || versionRecord?.downloadSrc); const downloadSrc = normalizeUploadSrcValue(versionRecord?.downloadSrc || sourceSrc); const artifactSrc = normalizeUploadSrcValue(versionRecord?.artifactSrc); const metadataSrc = normalizeUploadSrcValue(versionRecord?.metadataSrc); const sourceFormat = (stringOrNull(versionRecord?.sourceFormat) || guessViewerTypeFromSrc(downloadSrc || sourceSrc || "", "file") || "") .replace(/^\./, "") .toLowerCase(); const conversionRequired = sourceFormat && CONVERTIBLE_MODEL_FORMATS.has(`.${sourceFormat}`); const status = stringOrNull(versionRecord?.status) || (conversionRequired && !artifactSrc ? "conversion_required" : "ready"); const artifactType = stringOrNull(versionRecord?.artifactType || versionRecord?.targetFormat); const viewerSrc = status === "ready" && artifactSrc ? artifactSrc : (downloadSrc || sourceSrc); const viewerType = artifactSrc ? (artifactType || "gltf") : guessViewerTypeFromSrc(viewerSrc || "", sourceFormat); const conversion = conversionRequired ? { artifactSrc, artifactType: artifactType || null, componentTreeRequired: versionRecord?.componentTreeRequired !== false, message: stringOrNull(versionRecord?.message), metadataSrc, size: Number(versionRecord?.size) || 0, sourceFormat, sourceSrc, status, targetFormat: stringOrNull(versionRecord?.targetFormat) || "xkt", updatedAt: stringOrNull(versionRecord?.updatedAt || versionRecord?.uploadedAt) } : null; const previewAvailable = status === "ready" && !!viewerSrc && (!conversionRequired || !!artifactSrc); const viewerUrl = previewAvailable && viewerType ? (() => { const url = new URL("/", `${getRequestBaseUrl(req)}/`); url.searchParams.set("url", publicUrlForUploadSrc(req, viewerSrc)); url.searchParams.set("type", viewerType); url.searchParams.set("settingsSrc", publicUrlForUploadSrc(req, sourceSrc || downloadSrc || viewerSrc)); if (versionRecord?.originalFilename) url.searchParams.set("name", versionRecord.originalFilename); return url.toString(); })() : null; return { assetId: stringOrNull(versionRecord?.assetId), projectId: stringOrNull(versionRecord?.projectId), conversion, downloadUrl: downloadSrc ? publicUrlForUploadSrc(req, downloadSrc) : "", originalFilename: stringOrNull(versionRecord?.originalFilename) || (downloadSrc ? path.basename(downloadSrc) : "model"), previewAvailable, sha256: stringOrNull(versionRecord?.sha256), size: Number(versionRecord?.size) || 0, sourceSrc: sourceSrc || null, src: viewerSrc ? publicUrlForUploadSrc(req, viewerSrc) : "", status, type: viewerType || sourceFormat || "file", uploadedBy: stringOrNull(versionRecord?.uploadedBy), uploadedAt: stringOrNull(versionRecord?.uploadedAt || versionRecord?.updatedAt), version: Number(versionRecord?.version) || null, versionId: stringOrNull(versionRecord?.versionId), viewerUrl }; }; const getAssetVersionHistory = async (req, 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 null; } const hydratedVersions = await Promise.all( (Array.isArray(manifest.versions) ? manifest.versions : []).map(hydrateAssetVersionRecord) ); const versionsByKey = new Map(); hydratedVersions .filter((version) => Number.isFinite(Number(version?.version)) && Number(version.version) > 0) .forEach((version) => { const key = version.versionId || `version-${version.version}`; versionsByKey.set(key, mergeHydratedVersionRecords(versionsByKey.get(key), version)); }); const versions = [...versionsByKey.values()] .sort((first, second) => Number(first.version || 0) - Number(second.version || 0)) .map((version) => publicModelVersionRecord(req, version)); return { assetId: normalized.assetId, projectId: normalized.projectId, assetKey: normalized.assetKey, currentVersion: manifest.currentVersion || versions[versions.length - 1]?.version || null, currentVersionId: manifest.currentVersionId || versions[versions.length - 1]?.versionId || null, originalFilename: manifest.originalFilename || versions[versions.length - 1]?.originalFilename || null, updatedAt: manifest.updatedAt || null, versions }; }; const resolveAssetVersionIdentityFromSearchParams = (searchParams) => { const direct = normalizeAssetIdentity({ projectId: searchParams.get("projectId"), assetId: searchParams.get("assetId") }); if (direct) { return direct; } const src = normalizeUploadSrcValue( searchParams.get("src") || searchParams.get("url") || searchParams.get("sourceSrc") || searchParams.get("downloadSrc") || searchParams.get("artifactSrc") ); return src ? getAssetIdentityFromSrc(src) : null; }; const handleGetUploadVersions = async (req, res, searchParams) => { const identity = resolveAssetVersionIdentityFromSearchParams(searchParams); if (!identity) { sendText(res, 400, "Invalid model asset identity"); return; } const history = await getAssetVersionHistory(req, identity); if (!history) { sendText(res, 404, "Model asset versions not found"); return; } sendJSON(res, 200, { ok: true, ...history }); }; const handleAuthSession = async (req, res) => { const session = await getValidatedBimSession(req, res); const loginSyncEvent = getActiveBimLoginSyncEvent(); res.setHeader("Cache-Control", "no-store"); sendJSON(res, 200, { ok: true, authRequired: BIM_AUTH_REQUIRED, authenticated: !!session, canShare: !BIM_AUTH_REQUIRED || !!session, user: session?.user || null, loginSyncEventId: loginSyncEvent?.id || null, loginSyncEventCreatedAt: loginSyncEvent?.createdAt || null, loginUrl: session ? null : buildLauncherLoginUrl(req, "/") }); }; const handleLoginSync = (res) => { const event = markBimLoginSyncEvent(); setCors(res); res.statusCode = 200; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.setHeader("Cache-Control", "no-store"); res.setHeader("X-NODEDC-Session-Event", event.id); res.end("ok"); }; 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 handoff = await consumeLauncherHandoff(token); createBimSession(res, handoff.user, {launcherSessionId: handoff.launcherSessionId}); setBimHandoffCookie(res); 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 handleSharePromoted = (res, url) => { const nextPath = sanitizeReturnTo(url.searchParams.get("next") || "/"); const nextPathJson = JSON.stringify(nextPath); const promoted = url.searchParams.get("status") !== "guest"; res.statusCode = 200; res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Cache-Control", "no-store"); res.setHeader("Content-Security-Policy", "default-src 'none'; script-src 'unsafe-inline'"); res.end(`