@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.
@@ -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
+ };