@aaqu/fromcubes-portal-react 0.1.0-alpha.3 → 0.1.0-alpha.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +244 -76
- package/examples/D3 Poland Map.json +90 -0
- package/examples/Live Chart.json +101 -0
- package/examples/PixiJS Sprites.json +114 -0
- package/examples/Sensor Portal.json +93 -0
- package/examples/Shared Components.json +68 -0
- package/examples/Three.js Scene.json +118 -0
- package/examples/Utility Hooks.json +94 -0
- package/examples/WebGPU Shader.json +110 -0
- package/nodes/lib/admin-api.js +160 -0
- package/nodes/lib/assets.js +342 -0
- package/nodes/lib/helpers.js +724 -0
- package/nodes/lib/hooks.js +120 -0
- package/nodes/lib/page-builder.js +480 -0
- package/nodes/lib/portal-page-route.js +133 -0
- package/nodes/lib/registry-nodes.js +331 -0
- package/nodes/lib/router.js +80 -0
- package/nodes/lib/ws-heartbeat.js +58 -0
- package/nodes/portal-react.html +1862 -287
- package/nodes/portal-react.js +1563 -471
- package/package.json +42 -12
- package/examples/sensor-portal-flow.json +0 -71
- package/nodes/vendor/react-19.production.min.js +0 -55
- package/scripts/bundle-react.js +0 -31
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "fc-webgpu-flow",
|
|
4
|
+
"type": "tab",
|
|
5
|
+
"label": "WebGPU TSL Demo",
|
|
6
|
+
"disabled": false
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "8098c4fd7a54a6b0",
|
|
10
|
+
"type": "comment",
|
|
11
|
+
"z": "fc-webgpu-flow",
|
|
12
|
+
"name": "WebGPU TSL Shader Demo",
|
|
13
|
+
"info": "WebGPU-backed Three.js scene using the TSL (Three Shading Language) node graph API.\n\n## What it demonstrates\nModern `three` WebGPU renderer + TSL. Demonstrates that the bundler tolerates importing the\n`three/webgpu` subpath and that TSL helpers (`time`, `Fn`, …) work in user JSX.\n\n## How to run\n1. Import this flow, deploy (auto-installs `three`).\n2. Open `http://<host>:1880/fromcubes/webgpu` in a WebGPU-capable browser (Chrome ≥113, Safari ≥18).\n\n## Expected result\nAnimated shader effect. Browsers without WebGPU fall back to an error message in the canvas.",
|
|
14
|
+
"x": 160,
|
|
15
|
+
"y": 60,
|
|
16
|
+
"wires": []
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "fc-webgpu-comp-canvas",
|
|
20
|
+
"type": "fc-portal-component",
|
|
21
|
+
"z": "fc-webgpu-flow",
|
|
22
|
+
"compName": "TSLCanvas",
|
|
23
|
+
"compCode": "import { AmbientLight, Color, Mesh, MeshStandardNodeMaterial, PerspectiveCamera, PointLight, Scene, Timer, TorusKnotGeometry, WebGPURenderer } from 'three/webgpu';\nimport { uniform, sin, cos, time, uv, vec3, mix, float, positionLocal, normalLocal } from 'three/tsl';\n\nfunction TSLCanvas({ params }) {\n const mountRef = useRef(null);\n const stateRef = useRef(null);\n\n useEffect(() => {\n const el = mountRef.current;\n const w = el.clientWidth;\n const h = el.clientHeight;\n\n const scene = new Scene();\n const camera = new PerspectiveCamera(50, w / h, 0.1, 100);\n camera.position.z = 4;\n\n const renderer = new WebGPURenderer({ antialias: true });\n renderer.setSize(w, h);\n renderer.setPixelRatio(window.devicePixelRatio);\n el.appendChild(renderer.domElement);\n\n scene.add(new AmbientLight(0xffffff, 0.5));\n const point = new PointLight(0xffffff, 1.5, 100);\n point.position.set(5, 5, 5);\n scene.add(point);\n\n const colorAU = uniform(new Color(0x22d3ee));\n const colorBU = uniform(new Color(0xa78bfa));\n\n const t = time;\n const uvNode = uv();\n const wave = sin(uvNode.x.mul(6.28).add(t)).mul(0.5).add(0.5);\n const pulse = cos(t.mul(0.7)).mul(0.5).add(0.5);\n const blend = mix(vec3(colorAU), vec3(colorBU), wave.mul(pulse));\n\n const mat = new MeshStandardNodeMaterial();\n mat.colorNode = blend;\n mat.roughnessNode = float(0.3);\n mat.metalnessNode = float(0.7);\n\n const displaceAmount = sin(positionLocal.y.mul(4.0).add(t)).mul(0.08);\n mat.positionNode = positionLocal.add(normalLocal.mul(displaceAmount));\n\n const geo = new TorusKnotGeometry(1, 0.35, 128, 32);\n const mesh = new Mesh(geo, mat);\n scene.add(mesh);\n\n const targetA = new Color(0x22d3ee);\n const targetB = new Color(0xa78bfa);\n const st = { mesh, scene, camera, renderer, colorAU, colorBU, targetA, targetB };\n stateRef.current = st;\n\n let raf;\n const timer = new Timer();\n function animate() {\n raf = requestAnimationFrame(animate);\n timer.update();\n const dt = timer.getDelta();\n mesh.rotation.x += 0.3 * dt;\n mesh.rotation.y += 0.6 * dt;\n st.colorAU.value.lerp(st.targetA, Math.min(1, 2.5 * dt));\n st.colorBU.value.lerp(st.targetB, Math.min(1, 2.5 * dt));\n renderer.render(scene, camera);\n }\n\n renderer.init().then(() => animate());\n\n const onResize = () => {\n const w2 = el.clientWidth;\n const h2 = el.clientHeight;\n camera.aspect = w2 / h2;\n camera.updateProjectionMatrix();\n renderer.setSize(w2, h2);\n };\n window.addEventListener('resize', onResize);\n\n return () => {\n cancelAnimationFrame(raf);\n window.removeEventListener('resize', onResize);\n renderer.dispose();\n geo.dispose();\n mat.dispose();\n el.removeChild(renderer.domElement);\n };\n }, []);\n\n useEffect(() => {\n const s = stateRef.current;\n if (!s) return;\n s.targetA.set(params.colorA);\n s.targetB.set(params.colorB);\n }, [params.colorA, params.colorB]);\n\n return <div ref={mountRef} className=\"flex-1\" />;\n}",
|
|
24
|
+
"x": 160,
|
|
25
|
+
"y": 300,
|
|
26
|
+
"wires": []
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "fc-webgpu-comp-statusbar",
|
|
30
|
+
"type": "fc-portal-component",
|
|
31
|
+
"z": "fc-webgpu-flow",
|
|
32
|
+
"compName": "TSLStatusBar",
|
|
33
|
+
"compCode": "function TSLStatusBar({ params }) {\n return (\n <div className=\"px-6 py-4 bg-zinc-900/50 border-t border-zinc-800/50 flex items-center gap-6 flex-wrap\">\n <Stat label=\"Color A\">\n <span className=\"w-4 h-4 rounded-full border border-zinc-700\" style={{backgroundColor: params.colorA}} />\n </Stat>\n <Stat label=\"Color B\">\n <span className=\"w-4 h-4 rounded-full border border-zinc-700\" style={{backgroundColor: params.colorB}} />\n </Stat>\n <Stat label=\"Backend\">\n <span className=\"text-xs font-mono px-2 py-0.5 rounded bg-violet-500/20 text-violet-300\">WebGPU / TSL</span>\n </Stat>\n </div>\n );\n}",
|
|
34
|
+
"x": 360,
|
|
35
|
+
"y": 300,
|
|
36
|
+
"wires": []
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "fc-webgpu-inject",
|
|
40
|
+
"type": "inject",
|
|
41
|
+
"z": "fc-webgpu-flow",
|
|
42
|
+
"name": "Tick",
|
|
43
|
+
"props": [
|
|
44
|
+
{
|
|
45
|
+
"p": "payload"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"repeat": "3",
|
|
49
|
+
"payload": "{}",
|
|
50
|
+
"payloadType": "json",
|
|
51
|
+
"x": 160,
|
|
52
|
+
"y": 160,
|
|
53
|
+
"wires": [
|
|
54
|
+
[
|
|
55
|
+
"fc-webgpu-func"
|
|
56
|
+
]
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"id": "fc-webgpu-func",
|
|
61
|
+
"type": "function",
|
|
62
|
+
"z": "fc-webgpu-flow",
|
|
63
|
+
"name": "Random TSL params",
|
|
64
|
+
"func": "const pairs = [\n ['#22d3ee','#a78bfa'],\n ['#a78bfa','#f97316'],\n ['#34d399','#e879f9'],\n ['#f97316','#60a5fa'],\n ['#f87171','#34d399'],\n ['#e879f9','#22d3ee'],\n ['#fbbf24','#f87171']\n];\nconst p = pairs[Math.floor(Math.random() * pairs.length)];\nmsg.payload = { colorA: p[0], colorB: p[1] };\nreturn msg;",
|
|
65
|
+
"outputs": 1,
|
|
66
|
+
"x": 360,
|
|
67
|
+
"y": 160,
|
|
68
|
+
"wires": [
|
|
69
|
+
[
|
|
70
|
+
"fc-webgpu-portal"
|
|
71
|
+
]
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "fc-webgpu-portal",
|
|
76
|
+
"type": "portal-react",
|
|
77
|
+
"z": "fc-webgpu-flow",
|
|
78
|
+
"name": "WebGPU Portal",
|
|
79
|
+
"subPath": "webgpu",
|
|
80
|
+
"pageTitle": "fromcubes WebGPU",
|
|
81
|
+
"customHead": "",
|
|
82
|
+
"portalAuth": false,
|
|
83
|
+
"showWsStatus": false,
|
|
84
|
+
"libs": [
|
|
85
|
+
{
|
|
86
|
+
"module": "three",
|
|
87
|
+
"var": "THREE"
|
|
88
|
+
}
|
|
89
|
+
],
|
|
90
|
+
"componentCode": "function App() {\n const { data } = useNodeRed();\n const params = data || { colorA: '#22d3ee', colorB: '#a78bfa' };\n\n return (\n <Page>\n <Header title=\"fromcubes WebGPU\" subtitle=\"Three.js TSL + live Node-RED data\" />\n <TSLCanvas params={params} />\n <TSLStatusBar params={params} />\n </Page>\n );\n}",
|
|
91
|
+
"x": 560,
|
|
92
|
+
"y": 160,
|
|
93
|
+
"wires": [
|
|
94
|
+
[
|
|
95
|
+
"fc-webgpu-debug"
|
|
96
|
+
]
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"id": "fc-webgpu-debug",
|
|
101
|
+
"type": "debug",
|
|
102
|
+
"z": "fc-webgpu-flow",
|
|
103
|
+
"name": "WebGPU output",
|
|
104
|
+
"active": true,
|
|
105
|
+
"tosidebar": true,
|
|
106
|
+
"x": 750,
|
|
107
|
+
"y": 160,
|
|
108
|
+
"wires": []
|
|
109
|
+
}
|
|
110
|
+
]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Register editor/admin/public support routes for portal-react.
|
|
5
|
+
*
|
|
6
|
+
* This module intentionally owns only HTTP route registration. Runtime page
|
|
7
|
+
* state and registries remain owned by portal-react.js and are passed in so
|
|
8
|
+
* deploy/rebuild lifecycle stays in one place.
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} RED
|
|
11
|
+
* @param {Object} deps
|
|
12
|
+
* @param {import("express")} deps.express
|
|
13
|
+
* @param {Function} deps.permRead
|
|
14
|
+
* @param {Function} deps.permWrite
|
|
15
|
+
* @param {Function} deps.csrfGuard
|
|
16
|
+
* @param {Function} deps.rateLimit
|
|
17
|
+
* @param {string} deps.jsonBodyLimit
|
|
18
|
+
* @param {string} deps.userDir
|
|
19
|
+
* @param {Object<string, Object>} deps.pageState
|
|
20
|
+
* @param {Object<string, Object>} deps.registry
|
|
21
|
+
* @param {Object<string, Object>} deps.utilities
|
|
22
|
+
* @param {(code: string) => Set<string>} deps.extractUtilitySymbols
|
|
23
|
+
* @returns {void}
|
|
24
|
+
*/
|
|
25
|
+
function registerAdminApi(RED, deps) {
|
|
26
|
+
const {
|
|
27
|
+
express,
|
|
28
|
+
permRead,
|
|
29
|
+
permWrite,
|
|
30
|
+
csrfGuard,
|
|
31
|
+
rateLimit,
|
|
32
|
+
jsonBodyLimit,
|
|
33
|
+
userDir,
|
|
34
|
+
pageState,
|
|
35
|
+
registry,
|
|
36
|
+
utilities,
|
|
37
|
+
extractUtilitySymbols,
|
|
38
|
+
} = deps;
|
|
39
|
+
|
|
40
|
+
const monacoPath = path.dirname(
|
|
41
|
+
require.resolve("monaco-editor/package.json"),
|
|
42
|
+
);
|
|
43
|
+
RED.httpAdmin.use(
|
|
44
|
+
"/portal-react/vs",
|
|
45
|
+
permRead,
|
|
46
|
+
express.static(path.join(monacoPath, "min", "vs")),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const { generateCandidates } = require("../tw-candidates");
|
|
50
|
+
let twClassesCache = null;
|
|
51
|
+
RED.httpAdmin.get("/portal-react/tw-classes", permRead, (_req, res) => {
|
|
52
|
+
if (!twClassesCache) {
|
|
53
|
+
twClassesCache = generateCandidates();
|
|
54
|
+
}
|
|
55
|
+
res.json(twClassesCache);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const CSS_HASH_RE = /^[a-f0-9]{1,64}$/;
|
|
59
|
+
function findCssByHash(reqHash) {
|
|
60
|
+
for (const ep in pageState) {
|
|
61
|
+
if (pageState[ep]?.cssHash === reqHash) return pageState[ep].css;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function serveCss(req, res) {
|
|
66
|
+
const reqHash = req.params.hash;
|
|
67
|
+
if (!CSS_HASH_RE.test(reqHash)) {
|
|
68
|
+
res.status(400).send("Bad request");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const css = findCssByHash(reqHash);
|
|
72
|
+
if (!css) {
|
|
73
|
+
res.status(404).send("Not found");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
res.set({
|
|
77
|
+
"Content-Type": "text/css",
|
|
78
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
79
|
+
});
|
|
80
|
+
res.send(css);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
RED.httpNode.get("/fromcubes/css/:hash.css", serveCss);
|
|
84
|
+
RED.httpAdmin.get("/portal-react/css/:hash.css", serveCss);
|
|
85
|
+
|
|
86
|
+
const { registerAssets } = require("./assets");
|
|
87
|
+
registerAssets(RED, express, path.join(userDir, "fromcubes", "public"), {
|
|
88
|
+
csrfGuard,
|
|
89
|
+
rateLimit,
|
|
90
|
+
jsonLimit: jsonBodyLimit,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
RED.httpAdmin.get("/portal-react/registry", permRead, (_req, res) => {
|
|
94
|
+
res.json(registry);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
RED.httpAdmin.post(
|
|
98
|
+
"/portal-react/registry",
|
|
99
|
+
permWrite,
|
|
100
|
+
csrfGuard,
|
|
101
|
+
rateLimit,
|
|
102
|
+
express.json({ limit: jsonBodyLimit }),
|
|
103
|
+
(_req, res) => {
|
|
104
|
+
res.status(410).json({
|
|
105
|
+
error: "registry writes are deprecated; use fc-portal-component nodes",
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
RED.httpAdmin.delete(
|
|
111
|
+
"/portal-react/registry/:name",
|
|
112
|
+
permWrite,
|
|
113
|
+
csrfGuard,
|
|
114
|
+
rateLimit,
|
|
115
|
+
(_req, res) => {
|
|
116
|
+
res.status(410).json({
|
|
117
|
+
error: "registry writes are deprecated; use fc-portal-component nodes",
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
RED.httpAdmin.get("/portal-react/utilities", permRead, (_req, res) => {
|
|
123
|
+
const out = {};
|
|
124
|
+
for (const [name, u] of Object.entries(utilities)) {
|
|
125
|
+
out[name] = {
|
|
126
|
+
code: u.code,
|
|
127
|
+
error: u.error || null,
|
|
128
|
+
symbols: [...extractUtilitySymbols(u.code || "")],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
res.json(out);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
RED.httpAdmin.post(
|
|
135
|
+
"/portal-react/utilities",
|
|
136
|
+
permWrite,
|
|
137
|
+
csrfGuard,
|
|
138
|
+
rateLimit,
|
|
139
|
+
express.json({ limit: jsonBodyLimit }),
|
|
140
|
+
(_req, res) => {
|
|
141
|
+
res.status(410).json({
|
|
142
|
+
error: "utility writes are deprecated; use fc-portal-utility nodes",
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
RED.httpAdmin.delete(
|
|
148
|
+
"/portal-react/utilities/:name",
|
|
149
|
+
permWrite,
|
|
150
|
+
csrfGuard,
|
|
151
|
+
rateLimit,
|
|
152
|
+
(_req, res) => {
|
|
153
|
+
res.status(410).json({
|
|
154
|
+
error: "utility writes are deprecated; use fc-portal-utility nodes",
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { registerAdminApi };
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/** @module nodes/lib/assets */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Portal Assets — static file serving with security validation.
|
|
5
|
+
*
|
|
6
|
+
* Exports pure validators (for unit-testing without RED) and a
|
|
7
|
+
* `registerAssets` factory that mounts Express routes on `RED.httpAdmin`
|
|
8
|
+
* (admin CRUD, auth-gated) and `RED.httpNode` (public read-only serving).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} AssetEntry
|
|
13
|
+
* @property {string} name Relative path (POSIX, forward slashes).
|
|
14
|
+
* @property {"file"|"dir"} type
|
|
15
|
+
* @property {number} [size] Bytes — file only.
|
|
16
|
+
* @property {number} [mtime] fs.statSync.mtimeMs — file only.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} AssetsStats
|
|
21
|
+
* @property {number} size Total bytes across all files (excluding symlinks).
|
|
22
|
+
* @property {number} count Total file count.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require("fs");
|
|
26
|
+
const path = require("path");
|
|
27
|
+
|
|
28
|
+
// ── Constants ─────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const UNSAFE_EXTS = new Set([".html", ".htm", ".svg", ".js", ".mjs", ".xml", ".xhtml"]);
|
|
31
|
+
const RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM\d|LPT\d)(\.|$)/i;
|
|
32
|
+
const MAX_PATH_DEPTH = 10;
|
|
33
|
+
const MAX_ASSETS_BYTES = 500 * 1024 * 1024; // 500 MB total
|
|
34
|
+
const MAX_ASSETS_FILES = 1000;
|
|
35
|
+
|
|
36
|
+
// ── Pure validators ───────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* True when `s` is a single path segment safe to use under the assets root:
|
|
40
|
+
* non-empty string ≤255 chars, no Windows-illegal characters (`\:*?"<>|\0`),
|
|
41
|
+
* not a Windows reserved name (CON, PRN, …), no leading dot, no trailing
|
|
42
|
+
* `.` or space, and not the special `..` / `.` traversal markers.
|
|
43
|
+
*
|
|
44
|
+
* @param {*} s
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
function isSafePathSegment(s) {
|
|
48
|
+
return (
|
|
49
|
+
typeof s === "string" &&
|
|
50
|
+
s.length > 0 &&
|
|
51
|
+
s.length <= 255 &&
|
|
52
|
+
!/[\\:*?"<>|\0]/.test(s) &&
|
|
53
|
+
!s.startsWith(".") &&
|
|
54
|
+
!s.endsWith(".") &&
|
|
55
|
+
!s.endsWith(" ") &&
|
|
56
|
+
s !== ".." &&
|
|
57
|
+
!RESERVED_NAMES.test(s)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Combined relative-path cap. MAX_PATH_DEPTH=10 segments already constrains
|
|
62
|
+
// fan-out; this bounds total characters so an attacker can't craft a 10-segment
|
|
63
|
+
// path of 255-char names (~2.5 KB) that defeats higher-level URL length checks.
|
|
64
|
+
const MAX_REL_PATH_LEN = 1024;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a user-supplied relative path against `assetsDir`, applying every
|
|
68
|
+
* layer of path-traversal protection:
|
|
69
|
+
*
|
|
70
|
+
* 1. Non-empty string input.
|
|
71
|
+
* 2. Total length ≤ MAX_REL_PATH_LEN bytes.
|
|
72
|
+
* 3. Split on "/" and reject empty segments — `..`, `.`, `\0`, Windows
|
|
73
|
+
* reserved names (CON/PRN/AUX/NUL/COM1-9/LPT1-9), invalid characters,
|
|
74
|
+
* dotfiles, names ending in space/dot, length > 255 — see
|
|
75
|
+
* `isSafePathSegment`.
|
|
76
|
+
* 4. Segment count ≤ MAX_PATH_DEPTH.
|
|
77
|
+
* 5. After `path.resolve`, the resulting absolute path must stay inside
|
|
78
|
+
* `assetsDir + path.sep`.
|
|
79
|
+
* 6. If the path already exists on disk, `fs.realpathSync` must also stay
|
|
80
|
+
* inside `assetsDir` — blocks symlink-escape attacks.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} rel
|
|
83
|
+
* @param {string} assetsDir
|
|
84
|
+
* @returns {string|null} Absolute resolved path, or null if any check fails.
|
|
85
|
+
*/
|
|
86
|
+
function safePath(rel, assetsDir) {
|
|
87
|
+
if (!rel || typeof rel !== "string") return null;
|
|
88
|
+
if (rel.length > MAX_REL_PATH_LEN) return null;
|
|
89
|
+
if (rel.indexOf("\0") !== -1) return null;
|
|
90
|
+
const segments = rel.split("/").filter(Boolean);
|
|
91
|
+
if (segments.length === 0 || segments.length > MAX_PATH_DEPTH) return null;
|
|
92
|
+
if (!segments.every(isSafePathSegment)) return null;
|
|
93
|
+
const resolved = path.resolve(assetsDir, ...segments);
|
|
94
|
+
if (!resolved.startsWith(assetsDir + path.sep) && resolved !== assetsDir)
|
|
95
|
+
return null;
|
|
96
|
+
try {
|
|
97
|
+
const real = fs.realpathSync(resolved);
|
|
98
|
+
if (!real.startsWith(assetsDir + path.sep) && real !== assetsDir)
|
|
99
|
+
return null;
|
|
100
|
+
} catch (_e) { /* path doesn't exist yet — OK for mkdir/upload */ }
|
|
101
|
+
return resolved;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Filesystem helpers ────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Recursively list the contents of an assets directory. Symbolic links are
|
|
108
|
+
* skipped to defend against symlink-escape. Names are returned relative to
|
|
109
|
+
* the initial call (using `/` as separator).
|
|
110
|
+
*
|
|
111
|
+
* @param {string} dir Absolute filesystem directory to scan.
|
|
112
|
+
* @param {string} [prefix] Path prefix accumulated by recursion ("" at the root).
|
|
113
|
+
* @returns {Array<AssetEntry>}
|
|
114
|
+
*/
|
|
115
|
+
function scanDir(dir, prefix) {
|
|
116
|
+
const results = [];
|
|
117
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
118
|
+
if (entry.isSymbolicLink()) continue;
|
|
119
|
+
const rel = prefix ? prefix + "/" + entry.name : entry.name;
|
|
120
|
+
if (entry.isDirectory()) {
|
|
121
|
+
results.push({ name: rel, type: "dir" });
|
|
122
|
+
results.push(...scanDir(path.join(dir, entry.name), rel));
|
|
123
|
+
} else if (entry.isFile()) {
|
|
124
|
+
const stat = fs.statSync(path.join(dir, entry.name));
|
|
125
|
+
results.push({ name: rel, type: "file", size: stat.size, mtime: stat.mtimeMs });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return results;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Compute total file count and aggregate byte size under the assets root.
|
|
133
|
+
* Used to enforce the per-portal `MAX_ASSETS_BYTES` / `MAX_ASSETS_FILES`
|
|
134
|
+
* quotas before accepting an upload. Symbolic links are skipped. Filesystem
|
|
135
|
+
* errors (e.g. assets dir doesn't exist yet) yield `{size:0, count:0}`.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} assetsDir
|
|
138
|
+
* @returns {{size: number, count: number}}
|
|
139
|
+
*/
|
|
140
|
+
function getAssetsStats(assetsDir) {
|
|
141
|
+
let size = 0, count = 0;
|
|
142
|
+
function walk(dir) {
|
|
143
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
144
|
+
if (e.isSymbolicLink()) continue;
|
|
145
|
+
const p = path.join(dir, e.name);
|
|
146
|
+
if (e.isDirectory()) walk(p);
|
|
147
|
+
else if (e.isFile()) { size += fs.statSync(p).size; count++; }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
try { walk(assetsDir); } catch (_e) { /* ignore */ }
|
|
151
|
+
return { size, count };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Route registration ────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Resolve a `RED.auth.needsPermission(scope)` middleware, with a no-op fallback
|
|
158
|
+
* for runtimes (older Node-RED versions / test harnesses) where `RED.auth` is
|
|
159
|
+
* absent. Admin endpoints would otherwise be unprotected when an editor
|
|
160
|
+
* `adminAuth` is configured.
|
|
161
|
+
*
|
|
162
|
+
* @param {Object} RED
|
|
163
|
+
* @param {string} scope Permission name, e.g. `"portal-react.write"`.
|
|
164
|
+
* @returns {Function} Express middleware.
|
|
165
|
+
*/
|
|
166
|
+
function authMiddleware(RED, scope) {
|
|
167
|
+
if (RED.auth && typeof RED.auth.needsPermission === "function") {
|
|
168
|
+
return RED.auth.needsPermission(scope);
|
|
169
|
+
}
|
|
170
|
+
return function (_req, _res, next) { next(); };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Mount the Assets HTTP API on Node-RED's admin (write paths, auth-gated) and
|
|
175
|
+
* node (public read-only static) HTTP routers.
|
|
176
|
+
*
|
|
177
|
+
* @param {Object} RED Node-RED runtime object.
|
|
178
|
+
* @param {import('express')} express Express factory (for json/raw/static).
|
|
179
|
+
* @param {string} assetsDir Absolute filesystem path of the assets root.
|
|
180
|
+
* @param {Object} [opts] Optional middleware hooks supplied by the
|
|
181
|
+
* host module (CSRF, rate-limit) so the
|
|
182
|
+
* same protections apply to assets POSTs.
|
|
183
|
+
* @param {Function} [opts.csrfGuard]
|
|
184
|
+
* @param {Function} [opts.rateLimit]
|
|
185
|
+
* @param {string} [opts.jsonLimit] Body-parser limit (default "1mb").
|
|
186
|
+
* @returns {void}
|
|
187
|
+
*
|
|
188
|
+
* @fires RED.log#info on first-time directory creation
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* const { registerAssets } = require("./lib/assets");
|
|
192
|
+
* registerAssets(RED, require("express"), path.join(userDir, "fromcubes", "public"));
|
|
193
|
+
*/
|
|
194
|
+
function registerAssets(RED, express, assetsDir, opts) {
|
|
195
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
196
|
+
|
|
197
|
+
const READ = authMiddleware(RED, "portal-react.read");
|
|
198
|
+
const WRITE = authMiddleware(RED, "portal-react.write");
|
|
199
|
+
const passthrough = (_req, _res, next) => next();
|
|
200
|
+
const csrfGuard = (opts && opts.csrfGuard) || passthrough;
|
|
201
|
+
const rateLimit = (opts && opts.rateLimit) || passthrough;
|
|
202
|
+
const jsonLimit = (opts && opts.jsonLimit) || "1mb";
|
|
203
|
+
|
|
204
|
+
// Security middleware for public serving
|
|
205
|
+
RED.httpNode.use(
|
|
206
|
+
"/fromcubes/public",
|
|
207
|
+
(req, res, next) => {
|
|
208
|
+
res.set("X-Content-Type-Options", "nosniff");
|
|
209
|
+
res.set("Content-Security-Policy", "default-src 'none'");
|
|
210
|
+
const ext = path.extname(req.path).toLowerCase();
|
|
211
|
+
if (UNSAFE_EXTS.has(ext)) {
|
|
212
|
+
res.set("Content-Disposition", "attachment");
|
|
213
|
+
}
|
|
214
|
+
next();
|
|
215
|
+
},
|
|
216
|
+
// dotfiles: 'deny' → never serve .git/, .htaccess, .env, etc. that may
|
|
217
|
+
// have been written into assetsDir by mistake. fallthrough: false makes
|
|
218
|
+
// express.static return 404 directly instead of leaking to the next mw.
|
|
219
|
+
express.static(assetsDir, { maxAge: "1d", dotfiles: "deny", fallthrough: false }),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// List assets
|
|
223
|
+
RED.httpAdmin.get("/portal-react/assets", READ, (_req, res) => {
|
|
224
|
+
try {
|
|
225
|
+
res.json(scanDir(assetsDir, ""));
|
|
226
|
+
} catch (e) {
|
|
227
|
+
RED.log.error("portal-react assets list: " + e.message);
|
|
228
|
+
res.json([]);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Create directory
|
|
233
|
+
RED.httpAdmin.post("/portal-react/assets/mkdir", WRITE, csrfGuard, rateLimit, express.json({ limit: jsonLimit }), (req, res) => {
|
|
234
|
+
const target = safePath(req.body && req.body.path, assetsDir);
|
|
235
|
+
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
236
|
+
try {
|
|
237
|
+
fs.mkdirSync(target, { recursive: true });
|
|
238
|
+
res.json({ ok: true });
|
|
239
|
+
} catch (e) {
|
|
240
|
+
RED.log.error("portal-react assets mkdir: " + e.message);
|
|
241
|
+
res.status(500).json({ error: "internal error" });
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Move / rename
|
|
246
|
+
RED.httpAdmin.post("/portal-react/assets/move", WRITE, csrfGuard, rateLimit, express.json({ limit: jsonLimit }), (req, res) => {
|
|
247
|
+
const from = safePath(req.body && req.body.from, assetsDir);
|
|
248
|
+
const to = safePath(req.body && req.body.to, assetsDir);
|
|
249
|
+
if (!from || !to) return res.status(400).json({ error: "invalid path" });
|
|
250
|
+
const toName = path.basename(to);
|
|
251
|
+
if (!toName || !toName.trim()) return res.status(400).json({ error: "name cannot be empty" });
|
|
252
|
+
try {
|
|
253
|
+
const toDir = path.dirname(to);
|
|
254
|
+
fs.mkdirSync(toDir, { recursive: true });
|
|
255
|
+
fs.renameSync(from, to);
|
|
256
|
+
res.json({ ok: true });
|
|
257
|
+
} catch (e) {
|
|
258
|
+
RED.log.error("portal-react assets move: " + e.message);
|
|
259
|
+
res.status(500).json({ error: "internal error" });
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Upload — 100mb cap is intentionally higher than json/text endpoints.
|
|
264
|
+
RED.httpAdmin.post(
|
|
265
|
+
"/portal-react/assets/upload/*",
|
|
266
|
+
WRITE,
|
|
267
|
+
csrfGuard,
|
|
268
|
+
rateLimit,
|
|
269
|
+
express.raw({ type: "*/*", limit: "100mb" }),
|
|
270
|
+
(req, res) => {
|
|
271
|
+
const rel = req.params[0];
|
|
272
|
+
const target = safePath(rel, assetsDir);
|
|
273
|
+
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
274
|
+
// Guard: when nothing was uploaded (Content-Length 0 or wrong type),
|
|
275
|
+
// express.raw yields an empty {} object instead of a Buffer.
|
|
276
|
+
const bodyLen = Buffer.isBuffer(req.body) ? req.body.length : 0;
|
|
277
|
+
if (bodyLen === 0) {
|
|
278
|
+
return res.status(400).json({ error: "empty upload" });
|
|
279
|
+
}
|
|
280
|
+
const stats = getAssetsStats(assetsDir);
|
|
281
|
+
if (stats.size + bodyLen > MAX_ASSETS_BYTES)
|
|
282
|
+
return res.status(413).json({ error: "storage limit exceeded (500MB)" });
|
|
283
|
+
if (stats.count >= MAX_ASSETS_FILES)
|
|
284
|
+
return res.status(413).json({ error: "file count limit exceeded (1000)" });
|
|
285
|
+
try {
|
|
286
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
287
|
+
fs.writeFileSync(target, req.body);
|
|
288
|
+
res.json({ ok: true });
|
|
289
|
+
} catch (e) {
|
|
290
|
+
RED.log.error("portal-react assets upload: " + e.message);
|
|
291
|
+
res.status(500).json({ error: "internal error" });
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
// Delete
|
|
297
|
+
RED.httpAdmin.delete("/portal-react/assets/*", WRITE, csrfGuard, rateLimit, (req, res) => {
|
|
298
|
+
const rel = req.params[0];
|
|
299
|
+
const target = safePath(rel, assetsDir);
|
|
300
|
+
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
301
|
+
try {
|
|
302
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
303
|
+
res.json({ ok: true });
|
|
304
|
+
} catch (e) {
|
|
305
|
+
RED.log.error("portal-react assets delete: " + e.message);
|
|
306
|
+
res.status(404).json({ error: "not found" });
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Download
|
|
311
|
+
RED.httpAdmin.get("/portal-react/assets/download/*", READ, (req, res) => {
|
|
312
|
+
const rel = req.params[0];
|
|
313
|
+
const target = safePath(rel, assetsDir);
|
|
314
|
+
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
315
|
+
try {
|
|
316
|
+
const stat = fs.statSync(target);
|
|
317
|
+
if (stat.isDirectory()) return res.status(400).json({ error: "is a directory" });
|
|
318
|
+
const filename = path.basename(target);
|
|
319
|
+
res.set({
|
|
320
|
+
"Content-Disposition": 'attachment; filename="' + filename.replace(/"/g, '\\"') + '"',
|
|
321
|
+
"Content-Length": stat.size,
|
|
322
|
+
});
|
|
323
|
+
fs.createReadStream(target).pipe(res);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
res.status(404).json({ error: "not found" });
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
module.exports = {
|
|
331
|
+
UNSAFE_EXTS,
|
|
332
|
+
RESERVED_NAMES,
|
|
333
|
+
MAX_PATH_DEPTH,
|
|
334
|
+
MAX_REL_PATH_LEN,
|
|
335
|
+
MAX_ASSETS_BYTES,
|
|
336
|
+
MAX_ASSETS_FILES,
|
|
337
|
+
isSafePathSegment,
|
|
338
|
+
safePath,
|
|
339
|
+
scanDir,
|
|
340
|
+
getAssetsStats,
|
|
341
|
+
registerAssets,
|
|
342
|
+
};
|