Initial import of NODEDC BIM Viewer

This commit is contained in:
CODEX
2026-06-04 13:25:42 +03:00
commit 9aaf82862f
2075 changed files with 3592575 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# Server
Лёгкий файловый backend без внешних зависимостей.
Быстрый старт
- Сервер: `npm --prefix server start` (PORT=8080 по умолчанию). Если порт занят, сервер попробует 8081/8082/8083 автоматически.
- Ручной выбор порта: `PORT=8088 npm --prefix server start`
- Клиент отдельно (только статика, без API): `npx http-server frontend -p 9001` и открыть http://localhost:9001
Статика: раздаётся из `../frontend/dist`, при отсутствии сборки — из `../frontend`.
Хранилище: `server/data/projects/index.json` (список) + `server/data/projects/proj_<id>.json` (полный проект). При старте создаются при необходимости.
API:
- `GET /api/projects[?type=project|template]` — укороченный список из `index.json`.
- `GET /api/projects/:id` — полный JSON проекта.
- `POST /api/projects` — создать проект, генерируется `id`, `createdAt`, `updatedAt`, сохраняется файл и обновляется индекс.
Поле `ownerId` пока всегда `null`, но оставлено для будущих пользователей/шаринга.
+26
View File
@@ -0,0 +1,26 @@
[
{
"id": "proj_e5355c03-983b-4e2f-800c-f57638e9732f",
"name": "СКОЛКОВО - LOFT КВАРТАЛ",
"type": "project",
"ownerId": null,
"createdAt": "2025-12-09T15:34:20.896Z",
"updatedAt": "2025-12-18T15:54:18.262Z"
},
{
"id": "proj_d992cce8-c689-49f8-be13-c3fee3247218",
"name": "P5003 - Металлокаркас 09.07.2025",
"type": "project",
"ownerId": null,
"createdAt": "2025-12-09T15:06:15.733Z",
"updatedAt": "2025-12-09T15:06:15.733Z"
},
{
"id": "proj_605b2cad-e310-4967-a2ee-c16678255cc4",
"name": "МИД LAS",
"type": "project",
"ownerId": null,
"createdAt": "2025-12-08T23:44:33.594Z",
"updatedAt": "2025-12-19T01:24:59.236Z"
}
]
@@ -0,0 +1,863 @@
{
"id": "proj_605b2cad-e310-4967-a2ee-c16678255cc4",
"name": "МИД LAS",
"type": "project",
"createdAt": "2025-12-08T23:44:33.594Z",
"updatedAt": "2025-12-19T01:24:59.236Z",
"ownerId": null,
"shares": [],
"models": [
{
"id": "model-1",
"type": "las",
"meta": {
"label": "1.las.subsampled.subsampled.las",
"metadataPath": null
},
"visible": true,
"src": "uploads/a37cb27f-e557-4d00-8698-bd6014b7445b/1.las.subsampled.subsampled.las"
},
{
"id": "__40",
"type": "las",
"meta": {
"label": "5_4_Novinskiy_COMP.las",
"metadataPath": null
},
"visible": true,
"src": "uploads/proj_605b2cad-e310-4967-a2ee-c16678255cc4/5_4_Novinskiy_COMP.las"
},
{
"id": "03c92cdc-b969-46aa-8193-24d7effdec8a",
"type": "las",
"meta": {
"label": "64_SMOL_RESAMPLE__LAS2.las",
"metadataPath": null
},
"visible": true,
"src": "uploads/proj_605b2cad-e310-4967-a2ee-c16678255cc4/64_SMOL_RESAMPLE__LAS2.las"
},
{
"id": "model-2",
"type": "las",
"meta": {
"label": "6_3_Smolenskiy_bulvar_SAM.las",
"metadataPath": null
},
"visible": true,
"src": "uploads/proj_605b2cad-e310-4967-a2ee-c16678255cc4/6_3_Smolenskiy_bulvar_SAM.las"
},
{
"id": "model-3",
"type": "las",
"meta": {
"label": "6_1_Smolenskiy_bulvar_SAM.las",
"metadataPath": null
},
"visible": true,
"src": "uploads/proj_605b2cad-e310-4967-a2ee-c16678255cc4/6_1_Smolenskiy_bulvar_SAM.las"
}
],
"viewerState": {
"camera": {
"eye": [
3.290525254203974,
94.2468740377458,
-91.99238708709487
],
"look": [
62.67477802832284,
17.213604529191198,
12.108512669839874
],
"up": [
0.2679151175928213,
0.8412159002309669,
0.46965657555689466
],
"projection": "ortho"
},
"transparent": true,
"edges": true,
"addMode": true,
"selection": {
"ids": [
"a15f0848-72a2-4625-934c-554462aadf50"
],
"modelId": "__40"
},
"models": [
{
"id": "model-1",
"visible": true
},
{
"id": "__40",
"visible": true
},
{
"id": "03c92cdc-b969-46aa-8193-24d7effdec8a",
"visible": true
},
{
"id": "model-2",
"visible": true
},
{
"id": "model-3",
"visible": true
}
],
"transforms": {
"model-1": {
"__model__": {
"position": [
-161.792,
-25.79200002885279,
18.37489995300601
],
"rotation": [
-90,
0,
0
],
"scale": [
1,
1,
1
]
},
"764b1dc2-51c0-4cfc-b90d-68c2a586436c": {
"position": [
-161.8,
-25.79200002885279,
18.374899953006015
],
"rotation": [
-90,
0,
0
],
"scale": [
1,
1,
1
]
},
"49e0e645-3dc4-4f56-ae66-a344450742b0": {
"position": [
-161.792,
-25.79200002885279,
118.37489995300602
],
"rotation": [
-90,
0,
0
],
"scale": [
1,
1,
1
]
},
"65180f0f-af67-4ff1-984e-9bb2d4c79ca9": {
"position": [
-161.792,
-25.79200002885279,
118.37489995300602
],
"rotation": [
-90,
0,
0
],
"scale": [
1,
1,
1
]
},
"22f3b437-f0ad-4cbb-996b-1341085831fe": {
"position": [
-161.792,
-25.79200002885279,
180
],
"rotation": [
-90,
0,
0
],
"scale": [
1,
1,
1
]
},
"3aa81bcd-5c75-4373-a98a-3b8bbca490a6": {
"position": [
-161.792,
-25.79200002885279,
108.37489995300601
],
"rotation": [
-90,
0,
0
],
"scale": [
1,
1,
1
]
},
"1cd92b36-a933-48b8-b121-b0168b8f8268": {
"position": [
-161.792,
-25.79200002885279,
118.37489995300601
],
"rotation": [
-90,
0,
0
],
"scale": [
1,
1,
1
]
},
"e20cf78d-c2b0-42d0-9c33-bcd3e290f772": {
"position": [
-161.792,
-25.79200002885279,
18.37489995300601
],
"rotation": [
-90,
0,
0
],
"scale": [
1,
1,
1
]
},
"5086e5df-fb27-47f0-8427-e88da4cdedaf": {
"position": [
-161.792,
-25.79200002885279,
18.37489995300601
],
"rotation": [
90,
0,
0
],
"scale": [
1,
1,
1
]
},
"1d3bb865-3262-4699-902a-2ab4b8e04e71": {
"position": [
-161.792,
-25.79200002885279,
18.37489995300601
],
"rotation": [
-90,
0,
0
],
"scale": [
1,
1,
1
]
},
"5e0e1983-72f3-418b-a54a-c15b42b6c0f1": {
"position": [
-161.792,
-25.79200002885279,
18.37489995300601
],
"rotation": [
90,
0,
0
],
"scale": [
1,
1,
1
]
},
"b504c943-9ca6-47c9-b964-04f7d2c160d7": {
"position": [
-161.792,
-25.79200002885279,
18.37489995300601
],
"rotation": [
-90,
0,
0
],
"scale": [
1,
1,
1
]
},
"960718d6-e758-438e-9b53-ddb5a138096b": {
"position": [
-161.792,
-25.79200002885279,
18.37489995300601
],
"rotation": [
-90,
0,
0
],
"scale": [
1,
1,
1
]
}
},
"__40": {
"__model__": {
"position": [
198.3,
-25.79200002885279,
-5
],
"rotation": [
-90,
0,
-18
],
"scale": [
1,
1,
1
]
},
"eec1c0de-e7a0-4ef2-8101-2ced3e0c9f00": {
"position": [
198,
-25.79200002885279,
-7
],
"rotation": [
-90,
0,
-15
],
"scale": [
1,
1,
1
]
},
"b0bd4754-ac80-428c-bbdb-ff453f71bb69": {
"position": [
197.4,
-25.8,
-5.8
],
"rotation": [
-90,
0,
-17
],
"scale": [
1,
1,
1
]
},
"981415c6-7f89-426c-91d8-05a30bbf5949": {
"position": [
198,
-25.79200002885279,
-5
],
"rotation": [
-90,
0,
-19
],
"scale": [
1,
1,
1
]
},
"9eb2f67e-af0c-4e89-86cb-3574eeb1d00a": {
"position": [
198,
-25.79200002885279,
5
],
"rotation": [
-90,
0,
-19
],
"scale": [
1,
1,
1
]
},
"a15f0848-72a2-4625-934c-554462aadf50": {
"position": [
198.3,
-25.79200002885279,
-5
],
"rotation": [
-90,
0,
-18
],
"scale": [
1,
1,
1
]
}
},
"03c92cdc-b969-46aa-8193-24d7effdec8a": {
"__model__": {
"position": [
-737,
-24,
81
],
"rotation": [
-90,
0,
2
],
"scale": [
1,
1,
1
]
},
"f0ad97c7-862d-44d8-a949-ccca79f6182e": {
"position": [
-739,
-24,
61
],
"rotation": [
-90,
0,
0
],
"scale": [
1,
1,
1
]
},
"3596d42f-b80e-4d49-b841-e59a1ee5cd5c": {
"position": [
-739,
-23,
61
],
"rotation": [
-90,
0,
0
],
"scale": [
1,
1,
1
]
},
"189b283c-7435-4383-8c8e-29d951461172": {
"position": [
-738.792,
-23.79200002885279,
61
],
"rotation": [
-90,
0,
0
],
"scale": [
1,
1,
1
]
},
"da16c0a9-ff27-42d1-87ec-5f1bd4f45ca0": {
"position": [
-737,
-24,
81
],
"rotation": [
-90,
0,
2
],
"scale": [
1,
1,
1
]
},
"d08220d6-420e-4506-ae2b-a9677cf6d65f": {
"position": [
-737,
-24,
81
],
"rotation": [
-90,
0,
2
],
"scale": [
1,
1,
1
]
},
"8822f7a5-795f-4ef9-8047-bbbca730dc16": {
"position": [
-737,
-24,
81
],
"rotation": [
-90,
0,
0
],
"scale": [
1,
1,
1
]
},
"cc6645d1-c57f-4eff-b87f-84d20c8107ab": {
"position": [
-737,
-24,
81
],
"rotation": [
-90,
0,
2
],
"scale": [
1,
1,
1
]
}
},
"model-2": {
"8aeba79e-530c-428b-bc7c-1f6b2f192178": {
"position": [
0,
0,
0
],
"rotation": [
-90,
0,
0
],
"scale": [
1,
1,
1
]
},
"__model__": {
"position": [
-492,
-21,
-27
],
"rotation": [
-90,
0,
52
],
"scale": [
1,
1,
1
]
},
"ccfb97b0-2036-43f6-9d5e-5d283614edc5": {
"position": [
-490,
-19,
-21
],
"rotation": [
-90,
0,
51
],
"scale": [
1,
1,
1
]
},
"8f7edf5b-a247-493f-b47b-019edf5409d1": {
"position": [
-493,
-21,
-30
],
"rotation": [
-90,
0,
54
],
"scale": [
1,
1,
1
]
},
"ba4f5127-d048-4791-b787-6a6d82bb3d3d": {
"position": [
-493,
-21,
-30
],
"rotation": [
-90,
0,
54
],
"scale": [
1,
1,
1
]
},
"ff4b0e14-0307-43ed-aadb-3d0c7af7bc5c": {
"position": [
-493,
-21,
-30
],
"rotation": [
-90,
0,
54
],
"scale": [
1,
1,
1
]
},
"af708d8a-8d0c-4241-8a04-1a8e8d0ef889": {
"position": [
-492,
-21,
-27
],
"rotation": [
-90,
0,
52
],
"scale": [
1,
1,
1
]
}
},
"model-3": {
"581cee62-591e-43a3-951f-952bfce7fe76": {
"position": [
-4,
-18,
-47
],
"rotation": [
-90,
1,
180
],
"scale": [
1,
1,
1
]
},
"__model__": {
"position": [
-6,
-26,
-52
],
"rotation": [
-90,
2,
180
],
"scale": [
1,
1,
1
]
},
"925996f1-05b9-428a-8811-179fa1ff71c4": {
"position": [
-9,
-26,
-51
],
"rotation": [
-90,
2,
180
],
"scale": [
1,
1,
1
]
},
"eccea883-ac6a-480e-ba62-01b0847498fc": {
"position": [
-9,
-26,
-51
],
"rotation": [
-90,
2,
180
],
"scale": [
1,
1,
1
]
},
"c209454d-0a7e-48c0-a610-06fe1a3ae233": {
"position": [
-9,
-26,
-51
],
"rotation": [
-90,
2,
180
],
"scale": [
1,
1,
1
]
},
"f97f46b9-09c4-4c79-aae4-def5f982caf1": {
"position": [
-6,
-26,
-52
],
"rotation": [
-90,
2,
180
],
"scale": [
1,
1,
1
]
}
}
}
},
"design": {
"themeVersion": "v3-yellow",
"bgOuter": "#121319",
"canvasTop": "#121319",
"canvasBottom": "#121319",
"loadingTop": "#121319",
"loadingBottom": "#121319",
"accent": "#ffea00",
"accent2": "#ffea00",
"templateFill": "#1f1f1f",
"templateOutline": "#1c1c1c",
"templateOutlineHover": "#2e2e2e",
"projectBtnFill": "#1c1c1c",
"projectBtnBorder": "#1c1c1c",
"projectBtnHover": "#292929",
"modalBtnPrimary": "#befd02",
"modalBtnSecondary": "#292929",
"loaderColor": "#befd02",
"templateGlow": 39,
"toolbarBg": "#1c1c1c",
"toolbarBgActive": "#befd02",
"toolbarBorder": "#233044",
"toolbarOutline": "#befd02",
"modalBg": "#1a1a1a",
"modalBgAlpha": 0.95,
"modalFocus": "#141414",
"modalFocusAlpha": 0.55,
"modalBorder": "#141414",
"modalElement": "#141414",
"modalElementOutline": "#141414",
"modalRadius": "14px",
"border": "#233044",
"text": "#e6edf3",
"panel": "#1c1c1c",
"panel2": "#292929",
"highlight": "#befd02",
"cubeText": "#242424",
"cubeEdges": "#e0e7ff",
"plus": "#befd02",
"navCubeBase": "#ffffff",
"navCubeHover": "#ffffff",
"navCubeText": "#242424",
"navCubeEdge": "#e0e7ff",
"navCubeEdgeAlpha": 0.6,
"measureLine": "#befd02",
"measureLabel": "#befd02",
"measureLabelText": "#292929",
"measureDot": "#befd02",
"clipPerspectiveNear": 0.01,
"clipPerspectiveFar": 100000,
"clipOrthoNear": 0.01,
"clipOrthoFar": 100000,
"clipPerspective": 4000,
"clipOrtho": 5000
},
"meta": {
"description": "",
"tags": []
}
}
@@ -0,0 +1,120 @@
{
"id": "proj_d992cce8-c689-49f8-be13-c3fee3247218",
"name": "P5003 - Металлокаркас 09.07.2025",
"type": "project",
"createdAt": "2025-12-09T15:06:15.733Z",
"updatedAt": "2025-12-09T15:06:15.733Z",
"ownerId": null,
"shares": [],
"models": [
{
"id": "model-1",
"type": "xkt",
"meta": {
"label": "P5003.40-1.ifc.xkt",
"metadataPath": null
},
"visible": true,
"src": "uploads/8da2015f-7253-47bd-8080-4c92467c57eb/P5003.40-1.ifc.xkt"
}
],
"viewerState": {
"camera": {
"eye": [
2177739.6705636266,
85.26740285024928,
-584710.4811701652
],
"look": [
2177765.1888946714,
52.024988327600454,
-584662.7980467239
],
"up": [
0.24708334537413393,
0.8519312359162147,
0.4616957761437695
],
"projection": "perspective"
},
"transparent": true,
"edges": true,
"addMode": true,
"selection": {
"ids": [],
"modelId": null
},
"models": [
{
"id": "model-1",
"visible": true
}
],
"transforms": {
"model-1": {
"__model__": {
"position": [
0,
0,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
}
}
},
"design": {
"themeVersion": "v3-yellow",
"bgOuter": "#0f0f0f",
"canvasTop": "#1c1c1c",
"canvasBottom": "#1c1c1c",
"loadingTop": "#1c1c1c",
"loadingBottom": "#1c1c1c",
"accent": "#ffea00",
"accent2": "#ffea00",
"templateFill": "#1c1c1c",
"templateOutline": "#233044",
"templateOutlineHover": "rgba(255,234,0,0.7)",
"templateGlow": 80,
"toolbarBg": "#1c1c1c",
"toolbarBgActive": "#ffea00",
"toolbarBorder": "#233044",
"toolbarOutline": "#ffea00",
"modalBg": "#1c1c1c",
"modalFocus": "#292929",
"modalBorder": "#292929",
"modalElement": "#292929",
"modalElementOutline": "#2b2b2b",
"modalRadius": "14px",
"border": "#233044",
"text": "#e6edf3",
"panel": "#1c1c1c",
"panel2": "#292929",
"highlight": "#ffea00",
"cubeText": "#242424",
"cubeEdges": "#e0e7ff",
"plus": "#ffea00",
"navCubeBase": "#ffea00",
"navCubeHover": "#ffffff",
"navCubeText": "#242424",
"navCubeEdge": "#e0e7ff",
"navCubeEdgeAlpha": 0.6,
"measureLine": "#ffea00",
"measureLabel": "#ffea00",
"measureLabelText": "#292929",
"measureDot": "#ffea00"
},
"meta": {
"description": "",
"tags": []
}
}
@@ -0,0 +1,147 @@
{
"id": "proj_e5355c03-983b-4e2f-800c-f57638e9732f",
"name": "СКОЛКОВО - LOFT КВАРТАЛ",
"type": "project",
"createdAt": "2025-12-09T15:34:20.896Z",
"updatedAt": "2025-12-18T15:54:18.262Z",
"ownerId": null,
"shares": [],
"models": [
{
"id": "model-1",
"type": "gltf",
"meta": {
"label": "SK_LOFT.glb",
"metadataPath": null
},
"visible": true,
"src": "uploads/3fd1f5a5-6073-49d8-b8a3-ee73db24fe97/SK_LOFT.glb"
}
],
"viewerState": {
"camera": {
"eye": [
86.09374214401373,
85.39576390662415,
-80.17903586625827
],
"look": [
-163.28399976923995,
-285.2166475367679,
80.87648559581864
],
"up": [
-0.6556388781270804,
0.6251750694667361,
0.42343097903380306
],
"projection": "perspective"
},
"transparent": true,
"edges": true,
"addMode": true,
"selection": {
"ids": [
"_loft_"
],
"modelId": "model-1"
},
"models": [
{
"id": "model-1",
"visible": true
}
],
"transforms": {
"model-1": {
"__model__": {
"position": [
0,
0,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
},
"_loft_": {
"position": [
0,
0,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
}
}
},
"design": {
"themeVersion": "v3-yellow",
"bgOuter": "#0f0f0f",
"canvasTop": "#1c1c1c",
"canvasBottom": "#1c1c1c",
"loadingTop": "#1c1c1c",
"loadingBottom": "#1c1c1c",
"accent": "#ffea00",
"accent2": "#ffea00",
"templateFill": "#1c1c1c",
"templateOutline": "#233044",
"templateOutlineHover": "rgba(255,234,0,0.7)",
"projectBtnFill": "#1c1c1c",
"projectBtnBorder": "#1c1c1c",
"projectBtnHover": "#292929",
"modalBtnPrimary": "#befd02",
"modalBtnSecondary": "#292929",
"loaderColor": "#befd02",
"templateGlow": 80,
"toolbarBg": "#1c1c1c",
"toolbarBgActive": "#ffea00",
"toolbarBorder": "#233044",
"toolbarOutline": "#ffea00",
"modalBg": "#1c1c1c",
"modalBgAlpha": 0.95,
"modalFocus": "#292929",
"modalFocusAlpha": 0.55,
"modalBorder": "#292929",
"modalElement": "#292929",
"modalElementOutline": "#2b2b2b",
"modalRadius": "14px",
"border": "#233044",
"text": "#e6edf3",
"panel": "#1c1c1c",
"panel2": "#292929",
"highlight": "#ffea00",
"cubeText": "#242424",
"cubeEdges": "#e0e7ff",
"plus": "#ffea00",
"navCubeBase": "#ffea00",
"navCubeHover": "#ffffff",
"navCubeText": "#242424",
"navCubeEdge": "#e0e7ff",
"navCubeEdgeAlpha": 0.6,
"measureLine": "#ffea00",
"measureLabel": "#ffea00",
"measureLabelText": "#292929",
"measureDot": "#ffea00"
},
"meta": {
"description": "",
"tags": []
}
}
+557
View File
@@ -0,0 +1,557 @@
import http from "http";
import {createReadStream, createWriteStream, existsSync} from "fs";
import {promises as fs} from "fs";
import path from "path";
import {fileURLToPath} from "url";
import crypto from "crypto";
import {pipeline} from "stream/promises";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DATA_DIR = path.join(__dirname, "data", "projects");
const UPLOADS_DIR = path.join(__dirname, "data", "uploads");
const INDEX_FILE = path.join(DATA_DIR, "index.json");
const FRONTEND_DIST = path.join(__dirname, "..", "frontend", "dist");
const FRONTEND_ROOT = path.join(__dirname, "..", "frontend");
const PORT = parseInt(process.env.PORT, 10) || 8080;
const MIME_TYPES = {
".html": "text/html",
".js": "text/javascript",
".mjs": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".ico": "image/x-icon",
".glb": "model/gltf-binary",
".gltf": "model/gltf+json",
".xkt": "application/octet-stream",
".las": "application/octet-stream",
".laz": "application/octet-stream",
".bim": "application/octet-stream",
".wasm": "application/wasm",
".txt": "text/plain"
};
const ensureStorage = async () => {
await fs.mkdir(DATA_DIR, {recursive: true});
await fs.mkdir(UPLOADS_DIR, {recursive: true});
try {
await fs.access(INDEX_FILE);
} catch (err) {
await fs.writeFile(INDEX_FILE, "[]", "utf8");
}
};
const readIndex = async () => {
try {
const raw = await fs.readFile(INDEX_FILE, "utf8");
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (err) {
console.error("[server] failed to read index.json", err);
return [];
}
};
const writeIndex = async (items) => {
await fs.writeFile(INDEX_FILE, JSON.stringify(items, null, 2), "utf8");
};
const sendJSON = (res, status, payload) => {
setCors(res);
res.statusCode = status;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(payload));
};
const sendText = (res, status, message) => {
setCors(res);
res.statusCode = status;
res.setHeader("Content-Type", "text/plain");
res.end(message);
};
const setCors = (res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
};
const parseJSONBody = async (req) => {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
if (!chunks.length) {
return {};
}
const raw = Buffer.concat(chunks).toString("utf8");
if (!raw.trim()) {
return {};
}
try {
return JSON.parse(raw);
} catch (err) {
throw new Error("Invalid JSON");
}
};
const safeProjectId = (id) => {
if (typeof id !== "string") {
return null;
}
const trimmed = id.trim();
if (!/^[-_a-zA-Z0-9]+$/.test(trimmed)) {
return null;
}
return trimmed;
};
const STATIC_ROOTS = [
{prefix: "/uploads/", dir: UPLOADS_DIR},
{prefix: "/data/", dir: path.join(__dirname, "data")},
{prefix: "/", dir: FRONTEND_DIST},
{prefix: "/", dir: FRONTEND_ROOT}
];
const resolveStaticPath = async (pathname) => {
for (const {prefix, dir} of STATIC_ROOTS) {
if (!pathname.startsWith(prefix)) {
continue;
}
const rel = pathname.slice(prefix.length);
const resolved = path.resolve(dir, rel);
if (!resolved.startsWith(dir)) {
continue;
}
try {
const stats = await fs.stat(resolved);
if (stats.isFile()) {
return resolved;
}
} catch (_) {
// keep searching
}
}
return null;
};
const serveStatic = async (req, res, url) => {
const pathname = url.pathname || "/";
const targetPath = await resolveStaticPath(pathname);
if (!targetPath) {
const shouldFallback = pathname === "/" || !path.extname(pathname);
if (shouldFallback) {
const fallbackIndex = await resolveStaticPath("/index.html");
if (fallbackIndex) {
return serveStatic(req, res, new URL("/index.html", url));
}
}
sendText(res, 404, "Not Found");
return;
}
const ext = path.extname(targetPath).toLowerCase();
const mime = MIME_TYPES[ext] || "application/octet-stream";
res.statusCode = 200;
res.setHeader("Content-Type", mime);
createReadStream(targetPath).pipe(res).on("error", (err) => {
console.error("[server] failed to stream file", err);
if (!res.headersSent) {
sendText(res, 500, "Failed to read file");
} else {
res.destroy();
}
});
};
const decodeBase64 = (b64) => {
try {
return Buffer.from(b64, "base64");
} catch (err) {
return null;
}
};
const sanitizeFilename = (name, fallback) => {
if (!name || typeof name !== "string") {
return fallback;
}
const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_");
return safe || fallback;
};
const processUploads = async (project) => {
if (!Array.isArray(project.models)) {
return;
}
const projectDir = path.join(UPLOADS_DIR, project.id);
await fs.mkdir(projectDir, {recursive: true});
for (let i = 0; i < project.models.length; i++) {
const model = project.models[i];
if (!model || !model.srcData) {
continue;
}
const data = decodeBase64(model.srcData);
if (!data) {
throw new Error("Invalid srcData for model upload");
}
const ext = path.extname(model.srcFilename || "");
const baseName = sanitizeFilename(model.srcFilename || `${model.id || "model"}${ext || ".bin"}`, `${model.id || "model"}.bin`);
const filePath = path.join(projectDir, baseName);
await fs.writeFile(filePath, data);
model.src = path.join("uploads", project.id, baseName).replace(/\\/g, "/");
delete model.srcData;
delete model.srcFilename;
delete model.srcMime;
}
};
const handleRawUpload = async (req, res, url) => {
const filenameParam = url.searchParams.get("filename");
const filenameHeader = req.headers["x-filename"];
const rawName = filenameParam || (Array.isArray(filenameHeader) ? filenameHeader[0] : filenameHeader) || "upload.bin";
const safeName = sanitizeFilename(rawName, "upload.bin");
// if client passes projectId in query, group uploads per project
const projectId = safeProjectId(url.searchParams.get("projectId"));
const uploadId = projectId || (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2));
const dir = path.join(UPLOADS_DIR, uploadId);
await fs.mkdir(dir, {recursive: true});
const targetPath = path.join(dir, safeName);
const writeStream = createWriteStream(targetPath);
try {
await pipeline(req, writeStream);
} catch (err) {
console.error("[server] upload failed", err);
sendText(res, 500, "Upload failed");
return;
}
const src = path.join("uploads", uploadId, safeName).replace(/\\/g, "/");
sendJSON(res, 201, {src});
};
const buildProjectPayload = (body) => {
const now = new Date().toISOString();
const id = `proj_${crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)}`;
const name = typeof body.name === "string" ? body.name.trim() : "";
if (!name) {
throw new Error("Project name is required");
}
const type = body.type === "template" ? "template" : "project";
const models = Array.isArray(body.models) ? body.models : [];
const viewerState = body.viewerState && typeof body.viewerState === "object" ? body.viewerState : {};
const design = body.design && typeof body.design === "object" ? body.design : {};
const meta = body.meta && typeof body.meta === "object" ? body.meta : {description: "", tags: []};
return {
id,
name,
type,
createdAt: now,
updatedAt: now,
ownerId: null,
shares: [],
models,
viewerState,
design,
meta
};
};
const buildUpdatedProject = (body, existing) => {
const now = new Date().toISOString();
const name = typeof body.name === "string" ? body.name.trim() : "";
if (!name) {
throw new Error("Project name is required");
}
const type = body.type === "template" ? "template" : (body.type === "project" ? "project" : (existing.type || "project"));
const models = Array.isArray(body.models) ? body.models : (existing.models || []);
const viewerState = body.viewerState && typeof body.viewerState === "object" ? body.viewerState : (existing.viewerState || {});
const design = body.design && typeof body.design === "object" ? body.design : (existing.design || {});
const meta = body.meta && typeof body.meta === "object" ? body.meta : (existing.meta || {description: "", tags: []});
return {
id: existing.id,
name,
type,
createdAt: existing.createdAt || now,
updatedAt: now,
ownerId: existing.ownerId ?? null,
shares: Array.isArray(existing.shares) ? existing.shares : [],
models,
viewerState,
design,
meta
};
};
const handleGetProjects = async (res, searchParams) => {
const filterType = searchParams.get("type");
const items = await readIndex();
const filtered = filterType ? items.filter((item) => item.type === filterType) : items;
sendJSON(res, 200, filtered);
};
const handleGetProject = async (res, projectId) => {
const safeId = safeProjectId(projectId);
if (!safeId) {
sendText(res, 400, "Invalid project id");
return;
}
const projectPath = path.join(DATA_DIR, `${safeId}.json`);
if (!existsSync(projectPath)) {
sendText(res, 404, "Project not found");
return;
}
try {
const raw = await fs.readFile(projectPath, "utf8");
const parsed = JSON.parse(raw);
sendJSON(res, 200, parsed);
} catch (err) {
console.error("[server] failed to read project file", err);
sendText(res, 500, "Failed to read project");
}
};
const handleCreateProject = async (req, res) => {
let payload;
try {
payload = await parseJSONBody(req);
} catch (err) {
sendText(res, 400, err.message || "Invalid request body");
return;
}
try {
const project = buildProjectPayload(payload);
await processUploads(project);
const projectPath = path.join(DATA_DIR, `${project.id}.json`);
await fs.writeFile(projectPath, JSON.stringify(project, null, 2), "utf8");
const indexItems = await readIndex();
const summary = {
id: project.id,
name: project.name,
type: project.type,
ownerId: project.ownerId,
createdAt: project.createdAt,
updatedAt: project.updatedAt
};
const nextIndex = [summary, ...indexItems.filter((item) => item.id !== project.id)];
await writeIndex(nextIndex);
sendJSON(res, 201, project);
} catch (err) {
console.error("[server] failed to create project", err);
sendText(res, 400, err.message || "Failed to create project");
}
};
const handlePatchProject = async (req, res, projectId) => {
const safeId = safeProjectId(projectId);
if (!safeId) {
sendText(res, 400, "Invalid project id");
return;
}
const projectPath = path.join(DATA_DIR, `${safeId}.json`);
if (!existsSync(projectPath)) {
sendText(res, 404, "Project not found");
return;
}
let payload = {};
try {
payload = await parseJSONBody(req);
} catch (err) {
sendText(res, 400, err.message || "Invalid JSON");
return;
}
if (!payload || typeof payload !== "object") {
sendText(res, 400, "Invalid payload");
return;
}
try {
const raw = await fs.readFile(projectPath, "utf8");
const project = JSON.parse(raw);
const now = new Date().toISOString();
if (typeof payload.name === "string") {
project.name = payload.name.trim();
}
if (payload.type === "template" || payload.type === "project") {
project.type = payload.type;
}
project.updatedAt = now;
await fs.writeFile(projectPath, JSON.stringify(project, null, 2), "utf8");
const items = await readIndex();
const nextIndex = items.map((item) => {
if (item.id !== safeId) return item;
return {
...item,
name: project.name,
type: project.type,
updatedAt: project.updatedAt
};
});
await writeIndex(nextIndex);
sendJSON(res, 200, project);
} catch (err) {
console.error("[server] failed to patch project", err);
sendText(res, 500, "Failed to update project");
}
};
const handlePutProject = async (req, res, projectId) => {
const safeId = safeProjectId(projectId);
if (!safeId) {
sendText(res, 400, "Invalid project id");
return;
}
const projectPath = path.join(DATA_DIR, `${safeId}.json`);
if (!existsSync(projectPath)) {
sendText(res, 404, "Project not found");
return;
}
let payload = {};
try {
payload = await parseJSONBody(req);
} catch (err) {
sendText(res, 400, err.message || "Invalid JSON");
return;
}
try {
const raw = await fs.readFile(projectPath, "utf8");
const existing = JSON.parse(raw);
const updated = buildUpdatedProject(payload, { ...existing, id: safeId });
await fs.writeFile(projectPath, JSON.stringify(updated, null, 2), "utf8");
const items = await readIndex();
const nextIndex = items.map((item) => {
if (item.id !== safeId) return item;
return {
...item,
name: updated.name,
type: updated.type,
updatedAt: updated.updatedAt
};
});
await writeIndex(nextIndex);
sendJSON(res, 200, updated);
} catch (err) {
console.error("[server] failed to save project", err);
sendText(res, 500, "Failed to save project");
}
};
const handleDeleteProject = async (res, projectId) => {
const safeId = safeProjectId(projectId);
if (!safeId) {
sendText(res, 400, "Invalid project id");
return;
}
const projectPath = path.join(DATA_DIR, `${safeId}.json`);
if (!existsSync(projectPath)) {
sendText(res, 404, "Project not found");
return;
}
try {
await fs.unlink(projectPath);
} catch (err) {
console.error("[server] failed to delete project file", err);
}
// remove uploads folder if exists
const uploadDir = path.join(UPLOADS_DIR, safeId);
try {
const stat = await fs.stat(uploadDir);
if (stat.isDirectory()) {
await fs.rm(uploadDir, {recursive: true, force: true});
}
} catch (_) {
// ignore
}
const items = await readIndex();
const filtered = items.filter((item) => item.id !== safeId);
await writeIndex(filtered);
sendJSON(res, 200, {deleted: true, id: safeId});
};
const requestHandler = async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
if (req.method === "OPTIONS") {
setCors(res);
res.statusCode = 204;
res.end();
return;
}
if (url.pathname.startsWith("/api/projects")) {
if (req.method === "GET" && url.pathname === "/api/projects") {
return handleGetProjects(res, url.searchParams);
}
if (req.method === "GET" && /^\/api\/projects\/[^/]+/.test(url.pathname)) {
const projectId = url.pathname.split("/")[3];
return handleGetProject(res, projectId);
}
if (req.method === "POST" && url.pathname === "/api/projects") {
return handleCreateProject(req, res);
}
if (req.method === "PATCH" && /^\/api\/projects\/[^/]+/.test(url.pathname)) {
const projectId = url.pathname.split("/")[3];
return handlePatchProject(req, res, projectId);
}
if (req.method === "PUT" && /^\/api\/projects\/[^/]+/.test(url.pathname)) {
const projectId = url.pathname.split("/")[3];
return handlePutProject(req, res, projectId);
}
if (req.method === "DELETE" && /^\/api\/projects\/[^/]+/.test(url.pathname)) {
const projectId = url.pathname.split("/")[3];
return handleDeleteProject(res, projectId);
}
sendText(res, 405, "Method Not Allowed");
return;
}
if (req.method === "POST" && url.pathname === "/api/uploads") {
return handleRawUpload(req, res, url);
}
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);
});
+10
View File
@@ -0,0 +1,10 @@
{
"name": "xeokit-bim-viewer-server",
"private": true,
"type": "module",
"version": "0.1.0",
"description": "Lightweight file-based API for xeokit BIM viewer projects",
"scripts": {
"start": "node index.js"
}
}