@aaqu/fromcubes-portal-react 0.1.0-alpha.21 → 0.1.0-alpha.23
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 +91 -9
- package/examples/D3 Poland Map.json +90 -0
- package/examples/Live Chart.json +103 -0
- package/examples/PixiJS Sprites.json +118 -0
- package/examples/Sensor Portal.json +97 -0
- package/examples/Shared Components.json +78 -0
- package/examples/Three.js Scene.json +122 -0
- package/examples/Utility Hooks.json +96 -0
- package/examples/WebGPU Shader.json +114 -0
- package/nodes/lib/assets.js +141 -11
- package/nodes/lib/helpers.js +308 -12
- package/nodes/lib/hooks.js +38 -0
- package/nodes/lib/page-builder.js +145 -32
- package/nodes/lib/router.js +25 -1
- package/nodes/portal-react.html +826 -218
- package/nodes/portal-react.js +1193 -180
- package/package.json +20 -5
- package/examples/001-shared-components-flow.json +0 -68
- package/examples/002-sensor-portal-flow.json +0 -71
- package/examples/003-chart-portal-flow.json +0 -93
- package/examples/004-d3-poland-flow.json +0 -80
- package/examples/005-threejs-portal-flow.json +0 -87
- package/examples/006-pixi-portal-flow.json +0 -86
- package/examples/007-webgpu-tsl-flow.json +0 -85
package/nodes/lib/assets.js
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
|
+
/** @module nodes/lib/assets */
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Portal Assets — static file serving with security validation.
|
|
3
5
|
*
|
|
4
|
-
* Exports pure
|
|
5
|
-
* that mounts Express routes on RED.httpAdmin
|
|
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.
|
|
6
23
|
*/
|
|
7
24
|
|
|
8
25
|
const fs = require("fs");
|
|
@@ -18,6 +35,15 @@ const MAX_ASSETS_FILES = 1000;
|
|
|
18
35
|
|
|
19
36
|
// ── Pure validators ───────────────────────────────────────────
|
|
20
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
|
+
*/
|
|
21
47
|
function isSafePathSegment(s) {
|
|
22
48
|
return (
|
|
23
49
|
typeof s === "string" &&
|
|
@@ -32,8 +58,35 @@ function isSafePathSegment(s) {
|
|
|
32
58
|
);
|
|
33
59
|
}
|
|
34
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
|
+
*/
|
|
35
86
|
function safePath(rel, assetsDir) {
|
|
36
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;
|
|
37
90
|
const segments = rel.split("/").filter(Boolean);
|
|
38
91
|
if (segments.length === 0 || segments.length > MAX_PATH_DEPTH) return null;
|
|
39
92
|
if (!segments.every(isSafePathSegment)) return null;
|
|
@@ -50,6 +103,15 @@ function safePath(rel, assetsDir) {
|
|
|
50
103
|
|
|
51
104
|
// ── Filesystem helpers ────────────────────────────────────────
|
|
52
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
|
+
*/
|
|
53
115
|
function scanDir(dir, prefix) {
|
|
54
116
|
const results = [];
|
|
55
117
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -66,6 +128,15 @@ function scanDir(dir, prefix) {
|
|
|
66
128
|
return results;
|
|
67
129
|
}
|
|
68
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
|
+
*/
|
|
69
140
|
function getAssetsStats(assetsDir) {
|
|
70
141
|
let size = 0, count = 0;
|
|
71
142
|
function walk(dir) {
|
|
@@ -82,9 +153,54 @@ function getAssetsStats(assetsDir) {
|
|
|
82
153
|
|
|
83
154
|
// ── Route registration ────────────────────────────────────────
|
|
84
155
|
|
|
85
|
-
|
|
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) {
|
|
86
195
|
fs.mkdirSync(assetsDir, { recursive: true });
|
|
87
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
|
+
|
|
88
204
|
// Security middleware for public serving
|
|
89
205
|
RED.httpNode.use(
|
|
90
206
|
"/fromcubes/public",
|
|
@@ -97,20 +213,24 @@ function registerAssets(RED, express, assetsDir) {
|
|
|
97
213
|
}
|
|
98
214
|
next();
|
|
99
215
|
},
|
|
100
|
-
|
|
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 }),
|
|
101
220
|
);
|
|
102
221
|
|
|
103
222
|
// List assets
|
|
104
|
-
RED.httpAdmin.get("/portal-react/assets", (_req, res) => {
|
|
223
|
+
RED.httpAdmin.get("/portal-react/assets", READ, (_req, res) => {
|
|
105
224
|
try {
|
|
106
225
|
res.json(scanDir(assetsDir, ""));
|
|
107
226
|
} catch (e) {
|
|
227
|
+
RED.log.error("portal-react assets list: " + e.message);
|
|
108
228
|
res.json([]);
|
|
109
229
|
}
|
|
110
230
|
});
|
|
111
231
|
|
|
112
232
|
// Create directory
|
|
113
|
-
RED.httpAdmin.post("/portal-react/assets/mkdir", express.json(), (req, res) => {
|
|
233
|
+
RED.httpAdmin.post("/portal-react/assets/mkdir", WRITE, csrfGuard, rateLimit, express.json({ limit: jsonLimit }), (req, res) => {
|
|
114
234
|
const target = safePath(req.body && req.body.path, assetsDir);
|
|
115
235
|
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
116
236
|
try {
|
|
@@ -123,7 +243,7 @@ function registerAssets(RED, express, assetsDir) {
|
|
|
123
243
|
});
|
|
124
244
|
|
|
125
245
|
// Move / rename
|
|
126
|
-
RED.httpAdmin.post("/portal-react/assets/move", express.json(), (req, res) => {
|
|
246
|
+
RED.httpAdmin.post("/portal-react/assets/move", WRITE, csrfGuard, rateLimit, express.json({ limit: jsonLimit }), (req, res) => {
|
|
127
247
|
const from = safePath(req.body && req.body.from, assetsDir);
|
|
128
248
|
const to = safePath(req.body && req.body.to, assetsDir);
|
|
129
249
|
if (!from || !to) return res.status(400).json({ error: "invalid path" });
|
|
@@ -140,16 +260,25 @@ function registerAssets(RED, express, assetsDir) {
|
|
|
140
260
|
}
|
|
141
261
|
});
|
|
142
262
|
|
|
143
|
-
// Upload
|
|
263
|
+
// Upload — 100mb cap is intentionally higher than json/text endpoints.
|
|
144
264
|
RED.httpAdmin.post(
|
|
145
265
|
"/portal-react/assets/upload/*",
|
|
266
|
+
WRITE,
|
|
267
|
+
csrfGuard,
|
|
268
|
+
rateLimit,
|
|
146
269
|
express.raw({ type: "*/*", limit: "100mb" }),
|
|
147
270
|
(req, res) => {
|
|
148
271
|
const rel = req.params[0];
|
|
149
272
|
const target = safePath(rel, assetsDir);
|
|
150
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
|
+
}
|
|
151
280
|
const stats = getAssetsStats(assetsDir);
|
|
152
|
-
if (stats.size +
|
|
281
|
+
if (stats.size + bodyLen > MAX_ASSETS_BYTES)
|
|
153
282
|
return res.status(413).json({ error: "storage limit exceeded (500MB)" });
|
|
154
283
|
if (stats.count >= MAX_ASSETS_FILES)
|
|
155
284
|
return res.status(413).json({ error: "file count limit exceeded (1000)" });
|
|
@@ -165,7 +294,7 @@ function registerAssets(RED, express, assetsDir) {
|
|
|
165
294
|
);
|
|
166
295
|
|
|
167
296
|
// Delete
|
|
168
|
-
RED.httpAdmin.delete("/portal-react/assets/*", (req, res) => {
|
|
297
|
+
RED.httpAdmin.delete("/portal-react/assets/*", WRITE, csrfGuard, rateLimit, (req, res) => {
|
|
169
298
|
const rel = req.params[0];
|
|
170
299
|
const target = safePath(rel, assetsDir);
|
|
171
300
|
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
@@ -179,7 +308,7 @@ function registerAssets(RED, express, assetsDir) {
|
|
|
179
308
|
});
|
|
180
309
|
|
|
181
310
|
// Download
|
|
182
|
-
RED.httpAdmin.get("/portal-react/assets/download/*", (req, res) => {
|
|
311
|
+
RED.httpAdmin.get("/portal-react/assets/download/*", READ, (req, res) => {
|
|
183
312
|
const rel = req.params[0];
|
|
184
313
|
const target = safePath(rel, assetsDir);
|
|
185
314
|
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
@@ -202,6 +331,7 @@ module.exports = {
|
|
|
202
331
|
UNSAFE_EXTS,
|
|
203
332
|
RESERVED_NAMES,
|
|
204
333
|
MAX_PATH_DEPTH,
|
|
334
|
+
MAX_REL_PATH_LEN,
|
|
205
335
|
MAX_ASSETS_BYTES,
|
|
206
336
|
MAX_ASSETS_FILES,
|
|
207
337
|
isSafePathSegment,
|