NODEDC_BIM_VIEWER/server/index.js

3137 lines
107 KiB
JavaScript

import http from "http";
import {createReadStream, createWriteStream, existsSync} from "fs";
import {promises as fs} from "fs";
import path from "path";
import {fileURLToPath} from "url";
import crypto from "crypto";
import {pipeline} from "stream/promises";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
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 title = sanitizeCommentText(item.title ?? item.text ?? item.name, 300);
if (!title) {
return [];
}
return [{
id: stringOrNull(item.id) || `sub_${randomBase64Url(8)}`,
title,
done: 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);
});