@aaqu/fromcubes-react 0.0.0-stage → 0.2.0
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/LICENSE +190 -0
- package/README.md +435 -2
- package/THIRD-PARTY-NOTICES.md +164 -0
- 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/editor/ark-pixel.LICENSE.txt +96 -0
- package/nodes/editor/ark-pixel.woff2 +0 -0
- package/nodes/editor/file-tree.js +403 -0
- package/nodes/editor/format.js +182 -0
- package/nodes/editor/icons.svg +46 -0
- package/nodes/editor/mark.svg +5 -0
- package/nodes/editor/provider-settings.js +192 -0
- package/nodes/editor/workspace.css +660 -0
- package/nodes/editor/workspace.js +1565 -0
- package/nodes/icons/fromcubes-component.svg +27 -0
- package/nodes/icons/fromcubes-react.svg +25 -0
- package/nodes/icons/fromcubes-utility.svg +21 -0
- package/nodes/lib/admin-api.js +238 -0
- package/nodes/lib/admin-security.js +161 -0
- package/nodes/lib/api.js +29 -0
- package/nodes/lib/helpers.js +990 -0
- package/nodes/lib/hooks.js +145 -0
- package/nodes/lib/page-builder.js +499 -0
- package/nodes/lib/portal-page-route.js +131 -0
- package/nodes/lib/providers.js +581 -0
- package/nodes/lib/registry-nodes.js +331 -0
- package/nodes/lib/router.js +103 -0
- package/nodes/lib/ws-heartbeat.js +58 -0
- package/nodes/portal-react.html +2539 -0
- package/nodes/portal-react.js +1652 -0
- package/package.json +73 -4
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public portal page route factory.
|
|
3
|
+
*
|
|
4
|
+
* Owns only the HTTP response branching for a portal endpoint:
|
|
5
|
+
* building/teardown, hard build error, degraded last-good build, and fresh
|
|
6
|
+
* successful build. Runtime lifecycle, WebSocket handling, and pageState
|
|
7
|
+
* mutation stay in portal-react.js.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function buildBuildingPage(wsPath) {
|
|
11
|
+
return `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Building\u2026</title><style>@keyframes __sp{to{transform:rotate(360deg)}}body{font-family:monospace;background:#111;color:#888;margin:0;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center}</style></head><body><div style="font-size:24px;margin-bottom:16px">Building\u2026</div><div style="width:40px;height:40px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:__sp .8s linear infinite"></div><script>(function(){let r=0;function c(){const p=location.protocol==='https:'?'wss:':'ws:';const ws=new WebSocket(p+'//'+location.host+'${wsPath}');ws.onmessage=function(e){try{const m=JSON.parse(e.data);if((m.type==='version'&&m.hash)||m.type==='error')location.reload();}catch(_){}};ws.onclose=function(){const d=Math.min(500*Math.pow(2,r),8000);r++;setTimeout(c,d);};ws.onerror=function(){ws.close();};}c();})()</script></body></html>`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function withNoStore(res) {
|
|
15
|
+
return res.set("Cache-Control", "no-store");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Object} opts
|
|
20
|
+
* @param {string} opts.endpoint
|
|
21
|
+
* @param {Object<string, Object>} opts.pageState
|
|
22
|
+
* @param {string} opts.wsPath
|
|
23
|
+
* @param {string} opts.pageTitle
|
|
24
|
+
* @param {string} opts.nodeRoot
|
|
25
|
+
* @param {Function} opts.buildPage
|
|
26
|
+
* @param {Function} opts.buildErrorPage
|
|
27
|
+
* @param {function(Object): Promise<Object|null>} opts.resolveUser Identity provider (lib/providers).
|
|
28
|
+
* @returns {Function}
|
|
29
|
+
*/
|
|
30
|
+
function createPortalPageHandler(opts) {
|
|
31
|
+
const {
|
|
32
|
+
endpoint,
|
|
33
|
+
pageState,
|
|
34
|
+
wsPath,
|
|
35
|
+
pageTitle,
|
|
36
|
+
nodeRoot,
|
|
37
|
+
buildPage,
|
|
38
|
+
buildErrorPage,
|
|
39
|
+
resolveUser,
|
|
40
|
+
} = opts;
|
|
41
|
+
|
|
42
|
+
return async function portalPageHandler(req, res) {
|
|
43
|
+
let cssTimer = null;
|
|
44
|
+
try {
|
|
45
|
+
const state = pageState[endpoint];
|
|
46
|
+
if (!state || state.building || !state.compiled) {
|
|
47
|
+
const bWsPath = state?.wsPath || wsPath;
|
|
48
|
+
withNoStore(res)
|
|
49
|
+
.type("text/html")
|
|
50
|
+
.send(buildBuildingPage(bWsPath));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
withNoStore(res);
|
|
55
|
+
if (state.compiled.error) {
|
|
56
|
+
if (state.lastGood) {
|
|
57
|
+
const user = state.portalAuth ? await resolveUser(req) : null;
|
|
58
|
+
res
|
|
59
|
+
.type("text/html")
|
|
60
|
+
.send(
|
|
61
|
+
buildPage(
|
|
62
|
+
state.lastGood.pageTitle,
|
|
63
|
+
state.lastGood.compiledJs,
|
|
64
|
+
state.wsPath,
|
|
65
|
+
state.lastGood.customHead,
|
|
66
|
+
state.lastGood.cssHash,
|
|
67
|
+
user,
|
|
68
|
+
state.showWsStatus,
|
|
69
|
+
nodeRoot,
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
res
|
|
76
|
+
.status(500)
|
|
77
|
+
.type("text/html")
|
|
78
|
+
.send(
|
|
79
|
+
buildErrorPage(
|
|
80
|
+
state.pageTitle,
|
|
81
|
+
state.compiled.error,
|
|
82
|
+
state.wsPath,
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { cssHash } = await Promise.race([
|
|
89
|
+
state.cssReady,
|
|
90
|
+
new Promise((_, reject) => {
|
|
91
|
+
cssTimer = setTimeout(
|
|
92
|
+
() => reject(new Error("CSS generation timeout")),
|
|
93
|
+
15000,
|
|
94
|
+
);
|
|
95
|
+
}),
|
|
96
|
+
]);
|
|
97
|
+
// Clear the losing timer — without this every request leaves a 15 s
|
|
98
|
+
// timer pending after the CSS promise wins the race.
|
|
99
|
+
clearTimeout(cssTimer);
|
|
100
|
+
const user = state.portalAuth ? await resolveUser(req) : null;
|
|
101
|
+
res
|
|
102
|
+
.type("text/html")
|
|
103
|
+
.send(
|
|
104
|
+
buildPage(
|
|
105
|
+
state.pageTitle,
|
|
106
|
+
state.compiled.js,
|
|
107
|
+
state.wsPath,
|
|
108
|
+
state.customHead,
|
|
109
|
+
cssHash,
|
|
110
|
+
user,
|
|
111
|
+
state.showWsStatus,
|
|
112
|
+
nodeRoot,
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
clearTimeout(cssTimer);
|
|
117
|
+
res
|
|
118
|
+
.status(500)
|
|
119
|
+
.type("text/html")
|
|
120
|
+
.send(
|
|
121
|
+
buildErrorPage(
|
|
122
|
+
pageTitle,
|
|
123
|
+
"Page build failed: " + e.message,
|
|
124
|
+
wsPath,
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { createPortalPageHandler };
|
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
/** @module nodes/lib/providers */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pluggable providers for @aaqu/fromcubes-react.
|
|
5
|
+
*
|
|
6
|
+
* Two extension points, each a Node-RED plugin type:
|
|
7
|
+
*
|
|
8
|
+
* RED.plugins.registerPlugin("my-sso", {
|
|
9
|
+
* type: "fromcubes-identity",
|
|
10
|
+
* apiVersion: 1,
|
|
11
|
+
* resolveUser(req) { return { userId, login, displayName, email, role, groups } | null },
|
|
12
|
+
* })
|
|
13
|
+
*
|
|
14
|
+
* RED.plugins.registerPlugin("my-css", {
|
|
15
|
+
* type: "fromcubes-css",
|
|
16
|
+
* apiVersion: 1,
|
|
17
|
+
* generate(source) { return { css } },
|
|
18
|
+
* editor: { classes() { return ["p-4", …] } }, // optional: completion
|
|
19
|
+
* })
|
|
20
|
+
*
|
|
21
|
+
* The core builds in only `none` for each kind. Everything that does real
|
|
22
|
+
* work — `headers` (@aaqu/fromcubes-auth-headers) and
|
|
23
|
+
* `tailwind` (@aaqu/fromcubes-tailwind) — is a Node-RED plugin like any third-party one,
|
|
24
|
+
* found through RED.plugins and held to the same apiVersion and validation.
|
|
25
|
+
* Two plugins registering the same provider id of one type are ambiguous and
|
|
26
|
+
* neither is used.
|
|
27
|
+
*
|
|
28
|
+
* Selection is explicit, exactly one provider per kind, first match wins:
|
|
29
|
+
* 1. `RED.settings.portalReact.identityProvider` / `.cssEngine` (settings.js)
|
|
30
|
+
* — the administrator's word; the editor shows it locked.
|
|
31
|
+
* 2. The choice made in the editor (Settings → Fromcubes), persisted in
|
|
32
|
+
* `<userDir>/fromcubes/providers.json`.
|
|
33
|
+
* 3. The default: the official add-on when it is installed — `headers`
|
|
34
|
+
* from @aaqu/fromcubes-auth-headers, `tailwind` from
|
|
35
|
+
* @aaqu/fromcubes-tailwind — and `none` when it is not. The core works
|
|
36
|
+
* on its own; installing the add-on is what turns the feature on. Only
|
|
37
|
+
* the official package counts here: another package registering a
|
|
38
|
+
* plugin called `headers` never becomes the default identity provider
|
|
39
|
+
* (it can still be chosen explicitly, in 1 or 2).
|
|
40
|
+
* Installing a plugin never switches anything on its own — for identity that
|
|
41
|
+
* would let a package change who counts as logged in.
|
|
42
|
+
*
|
|
43
|
+
* Failure semantics:
|
|
44
|
+
* - identity: unknown provider, wrong apiVersion, throw, timeout or a result
|
|
45
|
+
* that does not validate ⇒ anonymous (`null`). Fail closed: anonymous is the
|
|
46
|
+
* least privileged answer.
|
|
47
|
+
* - css: unknown provider or wrong apiVersion ⇒ `generate` rejects, which the
|
|
48
|
+
* build turns into the yellow `css-fail` status (page still served).
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
const IDENTITY_TYPE = "fromcubes-identity";
|
|
52
|
+
const CSS_TYPE = "fromcubes-css";
|
|
53
|
+
const API_VERSION = 1;
|
|
54
|
+
|
|
55
|
+
const DEFAULT_IDENTITY = "headers";
|
|
56
|
+
const DEFAULT_CSS = "tailwind";
|
|
57
|
+
const NONE = "none";
|
|
58
|
+
|
|
59
|
+
// Bounds on what a provider may hand us. Generous enough that anything the
|
|
60
|
+
// built-in header parser produces passes untouched (its groups header is
|
|
61
|
+
// capped at 8 KB, so at most ~4096 one-char groups); anything above is
|
|
62
|
+
// dropped rather than cut, same reasoning as the groups header: a shortened
|
|
63
|
+
// value could match a different, real one.
|
|
64
|
+
const MAX_USER_STRING = 8192;
|
|
65
|
+
const MAX_GROUPS = 4096;
|
|
66
|
+
const IDENTITY_TIMEOUT_MS = 5000;
|
|
67
|
+
// Completion list sent to the editor. Tailwind's own list is a few thousand
|
|
68
|
+
// entries; the caps only stop a plugin from shipping something absurd to
|
|
69
|
+
// every editor that opens a dialog.
|
|
70
|
+
const MAX_EDITOR_CLASSES = 50000;
|
|
71
|
+
const MAX_EDITOR_CLASS_LEN = 200;
|
|
72
|
+
|
|
73
|
+
const USER_STRING_FIELDS = ["login", "displayName", "email", "role"];
|
|
74
|
+
|
|
75
|
+
const KINDS = {
|
|
76
|
+
identity: {
|
|
77
|
+
type: IDENTITY_TYPE,
|
|
78
|
+
setting: "identityProvider",
|
|
79
|
+
fallback: DEFAULT_IDENTITY,
|
|
80
|
+
fallbackModule: "@aaqu/fromcubes-auth-headers",
|
|
81
|
+
},
|
|
82
|
+
css: {
|
|
83
|
+
type: CSS_TYPE,
|
|
84
|
+
setting: "cssEngine",
|
|
85
|
+
fallback: DEFAULT_CSS,
|
|
86
|
+
fallbackModule: "@aaqu/fromcubes-tailwind",
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Provider ids travel through the settings file, the admin API and the
|
|
91
|
+
// editor's DOM. Plugin ids are free-form in Node-RED; anything outside this
|
|
92
|
+
// shape can be registered but not selected.
|
|
93
|
+
const PROVIDER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Error from `select()`, with a code the admin API maps to an HTTP status.
|
|
97
|
+
*/
|
|
98
|
+
class ProviderSelectError extends Error {
|
|
99
|
+
/**
|
|
100
|
+
* @param {"bad-kind"|"bad-id"|"locked"|"unknown"} code
|
|
101
|
+
* @param {string} message
|
|
102
|
+
*/
|
|
103
|
+
constructor(code, message) {
|
|
104
|
+
super(message);
|
|
105
|
+
this.code = code;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {*} v
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
function isBoundedString(v) {
|
|
114
|
+
return typeof v === "string" && v.length > 0 && v.length <= MAX_USER_STRING;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Normalize a provider's answer into a PortalUser, or null. Only the known
|
|
119
|
+
* fields are copied — never the provider's object itself — so a provider
|
|
120
|
+
* cannot smuggle extra keys (or a getter, or a prototype) into `msg._client`
|
|
121
|
+
* and `useNodeRed().user`.
|
|
122
|
+
*
|
|
123
|
+
* @param {*} raw
|
|
124
|
+
* @returns {import("./helpers").PortalUser|null}
|
|
125
|
+
*/
|
|
126
|
+
function sanitizeUser(raw) {
|
|
127
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
128
|
+
if (!isBoundedString(raw.userId)) return null;
|
|
129
|
+
const user = { userId: raw.userId };
|
|
130
|
+
for (const field of USER_STRING_FIELDS) {
|
|
131
|
+
if (isBoundedString(raw[field])) user[field] = raw[field];
|
|
132
|
+
}
|
|
133
|
+
const groups = [];
|
|
134
|
+
if (Array.isArray(raw.groups) && raw.groups.length <= MAX_GROUPS) {
|
|
135
|
+
for (const g of raw.groups) if (isBoundedString(g)) groups.push(g);
|
|
136
|
+
}
|
|
137
|
+
user.groups = groups;
|
|
138
|
+
return user;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The only providers built into the core: one per kind that does nothing, so
|
|
143
|
+
* "no identity" and "no stylesheet" are always choosable. Their ids are
|
|
144
|
+
* reserved.
|
|
145
|
+
*
|
|
146
|
+
* @returns {{ identity: Object<string,Object>, css: Object<string,Object> }}
|
|
147
|
+
*/
|
|
148
|
+
function builtinProviders() {
|
|
149
|
+
return {
|
|
150
|
+
identity: {
|
|
151
|
+
none: {
|
|
152
|
+
id: "none",
|
|
153
|
+
type: IDENTITY_TYPE,
|
|
154
|
+
apiVersion: API_VERSION,
|
|
155
|
+
label: "Nobody is identified — every session is anonymous",
|
|
156
|
+
resolveUser: () => null,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
css: {
|
|
160
|
+
none: {
|
|
161
|
+
id: "none",
|
|
162
|
+
type: CSS_TYPE,
|
|
163
|
+
apiVersion: API_VERSION,
|
|
164
|
+
label: "No stylesheet",
|
|
165
|
+
generate: () => ({ css: "" }),
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Create the provider registry bound to `RED`.
|
|
173
|
+
*
|
|
174
|
+
* @param {Object} RED
|
|
175
|
+
* @param {{ identityTimeoutMs?: number, storeFile?: string }} [opts]
|
|
176
|
+
* `storeFile`: where the editor's choice is persisted; without it the
|
|
177
|
+
* choice lives in memory only (tests, embedded use).
|
|
178
|
+
* @returns {{
|
|
179
|
+
* resolveUser: function(Object): Promise<Object|null>,
|
|
180
|
+
* generateCss: function(string): Promise<{css: string}>,
|
|
181
|
+
* identityId: function(): string,
|
|
182
|
+
* cssEngineId: function(): string,
|
|
183
|
+
* describe: function(): Object,
|
|
184
|
+
* select: function(string, string): boolean,
|
|
185
|
+
* editorClasses: function(): Promise<{engine: string, classes: Array<string>}>,
|
|
186
|
+
* }}
|
|
187
|
+
*/
|
|
188
|
+
function createProviders(RED, opts = {}) {
|
|
189
|
+
const fs = require("fs");
|
|
190
|
+
const path = require("path");
|
|
191
|
+
const builtins = builtinProviders();
|
|
192
|
+
const tables = { identity: builtins.identity, css: builtins.css };
|
|
193
|
+
const identityTimeoutMs = opts.identityTimeoutMs ?? IDENTITY_TIMEOUT_MS;
|
|
194
|
+
const storeFile = opts.storeFile || null;
|
|
195
|
+
const logged = new Set();
|
|
196
|
+
/** @type {{identity?: string, css?: string}} */
|
|
197
|
+
const chosen = loadChoice();
|
|
198
|
+
// provider object → its validated class list. Keyed by the object, not the
|
|
199
|
+
// id, so a plugin reinstalled at runtime (a new definition) is asked again.
|
|
200
|
+
const classCache = new WeakMap();
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Log a configuration problem once per process — these are evaluated per
|
|
204
|
+
* request, and a misconfigured provider must not flood the log.
|
|
205
|
+
* @param {string} msg
|
|
206
|
+
* @returns {void}
|
|
207
|
+
*/
|
|
208
|
+
function logOnce(msg) {
|
|
209
|
+
if (logged.has(msg)) return;
|
|
210
|
+
logged.add(msg);
|
|
211
|
+
RED.log.error("[portal-react] " + msg);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** @returns {Object} */
|
|
215
|
+
function cfg() {
|
|
216
|
+
return (RED.settings && RED.settings.portalReact) || {};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Read the editor's persisted choice. A missing file is the normal first
|
|
221
|
+
* run; a malformed one is reported and ignored (defaults apply) rather
|
|
222
|
+
* than stopping Node-RED.
|
|
223
|
+
* @returns {{identity?: string, css?: string}}
|
|
224
|
+
*/
|
|
225
|
+
function loadChoice() {
|
|
226
|
+
if (!storeFile) return {};
|
|
227
|
+
let raw;
|
|
228
|
+
try {
|
|
229
|
+
raw = fs.readFileSync(storeFile, "utf8");
|
|
230
|
+
} catch (e) {
|
|
231
|
+
if (e.code !== "ENOENT") {
|
|
232
|
+
RED.log.warn(`[portal-react] cannot read ${storeFile}: ${e.message}`);
|
|
233
|
+
}
|
|
234
|
+
return {};
|
|
235
|
+
}
|
|
236
|
+
let data;
|
|
237
|
+
try {
|
|
238
|
+
data = JSON.parse(raw);
|
|
239
|
+
} catch (e) {
|
|
240
|
+
RED.log.warn(`[portal-react] ignoring malformed ${storeFile}: ${e.message}`);
|
|
241
|
+
return {};
|
|
242
|
+
}
|
|
243
|
+
const out = {};
|
|
244
|
+
if (data && typeof data === "object") {
|
|
245
|
+
for (const kind of Object.keys(KINDS)) {
|
|
246
|
+
if (typeof data[kind] === "string" && PROVIDER_ID_RE.test(data[kind])) {
|
|
247
|
+
out[kind] = data[kind];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Persist the choice atomically: a crash mid-write must not leave a
|
|
256
|
+
* half-written file that silently resets identity to the default.
|
|
257
|
+
* @returns {void}
|
|
258
|
+
*/
|
|
259
|
+
function saveChoice() {
|
|
260
|
+
if (!storeFile) return;
|
|
261
|
+
fs.mkdirSync(path.dirname(storeFile), { recursive: true });
|
|
262
|
+
const tmp = storeFile + ".tmp";
|
|
263
|
+
fs.writeFileSync(tmp, JSON.stringify(chosen, null, 2) + "\n", "utf8");
|
|
264
|
+
fs.renameSync(tmp, storeFile);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The provider id in force for `kind`, and where it came from.
|
|
269
|
+
* @param {"identity"|"css"} kind
|
|
270
|
+
* @returns {{ id: string, source: "settings"|"editor"|"default" }}
|
|
271
|
+
*/
|
|
272
|
+
function current(kind) {
|
|
273
|
+
const k = KINDS[kind];
|
|
274
|
+
const fromSettings = cfg()[k.setting];
|
|
275
|
+
if (typeof fromSettings === "string" && fromSettings) {
|
|
276
|
+
return { id: fromSettings, source: "settings" };
|
|
277
|
+
}
|
|
278
|
+
if (chosen[kind]) return { id: chosen[kind], source: "editor" };
|
|
279
|
+
const official = pluginsOf(k.type).some(
|
|
280
|
+
(p) => p.id === k.fallback && p.module === k.fallbackModule,
|
|
281
|
+
);
|
|
282
|
+
return { id: official ? k.fallback : NONE, source: "default", addonMissing: !official };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Log once, at info level, why a kind runs on `none` by default — so a
|
|
287
|
+
* core installed on its own explains itself instead of failing quietly.
|
|
288
|
+
* @param {string} kind
|
|
289
|
+
* @param {string} msg
|
|
290
|
+
* @returns {void}
|
|
291
|
+
*/
|
|
292
|
+
function explainNone(kind, msg) {
|
|
293
|
+
const key = "none:" + kind;
|
|
294
|
+
if (logged.has(key)) return;
|
|
295
|
+
logged.add(key);
|
|
296
|
+
if (RED.log.info) RED.log.info("[portal-react] " + msg);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Plugins registered with Node-RED for `type`. Never throws.
|
|
301
|
+
* @param {string} type
|
|
302
|
+
* @returns {Array<Object>}
|
|
303
|
+
*/
|
|
304
|
+
function pluginsOf(type) {
|
|
305
|
+
try {
|
|
306
|
+
return ((RED.plugins && RED.plugins.getByType(type)) || []).filter(Boolean);
|
|
307
|
+
} catch (_) {
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Find the provider `id` of `type`: built-ins first (their ids are
|
|
314
|
+
* reserved), then plugins registered with Node-RED. An id registered by
|
|
315
|
+
* more than one plugin is refused rather than resolved by load order — for
|
|
316
|
+
* identity, "whichever package loaded first" must never decide who is
|
|
317
|
+
* logged in.
|
|
318
|
+
*
|
|
319
|
+
* @param {string} type
|
|
320
|
+
* @param {Object<string,Object>} table
|
|
321
|
+
* @param {string} id
|
|
322
|
+
* @returns {{ provider: Object|null, error: string|null }}
|
|
323
|
+
*/
|
|
324
|
+
function lookup(type, table, id) {
|
|
325
|
+
const plugins = pluginsOf(type);
|
|
326
|
+
// Scanned before the built-in short-circuit: a plugin shadowing a
|
|
327
|
+
// built-in id must be reported even while the built-in is in use.
|
|
328
|
+
for (const p of plugins) {
|
|
329
|
+
if (p && Object.prototype.hasOwnProperty.call(table, p.id)) {
|
|
330
|
+
logOnce(`${type} plugin "${p.id}" ignored: id is reserved for a built-in`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (Object.prototype.hasOwnProperty.call(table, id)) {
|
|
334
|
+
return { provider: table[id], error: null };
|
|
335
|
+
}
|
|
336
|
+
const matches = plugins.filter((p) => p && p.id === id);
|
|
337
|
+
if (matches.length === 0) {
|
|
338
|
+
return { provider: null, error: `${type} provider "${id}" is not registered` };
|
|
339
|
+
}
|
|
340
|
+
if (matches.length > 1) {
|
|
341
|
+
const mods = matches.map((p) => p.module || "?").join(", ");
|
|
342
|
+
return {
|
|
343
|
+
provider: null,
|
|
344
|
+
error: `${type} provider "${id}" is registered by more than one package (${mods})`,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
const found = matches[0];
|
|
348
|
+
if (found.apiVersion !== API_VERSION) {
|
|
349
|
+
return {
|
|
350
|
+
provider: null,
|
|
351
|
+
error: `${type} provider "${id}" has apiVersion ${JSON.stringify(found.apiVersion)}, expected ${API_VERSION}`,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
return { provider: found, error: null };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** @returns {string} */
|
|
358
|
+
function identityId() {
|
|
359
|
+
return current("identity").id;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** @returns {string} */
|
|
363
|
+
function cssEngineId() {
|
|
364
|
+
return current("css").id;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Everything the editor's settings pane needs: per kind, what is in force,
|
|
369
|
+
* where it came from, whether it can be changed, and every provider that
|
|
370
|
+
* could be chosen. A selected id that is no longer registered stays in
|
|
371
|
+
* the list, flagged `missing`, so the editor can say so instead of
|
|
372
|
+
* silently showing nothing selected.
|
|
373
|
+
*
|
|
374
|
+
* @returns {Object<string, {selected: string, source: string, locked: boolean, missing: boolean,
|
|
375
|
+
* options: Array<{id: string, label: string, module: string, builtin: boolean, missing?: boolean, problem?: string}>}>}
|
|
376
|
+
*/
|
|
377
|
+
function describe() {
|
|
378
|
+
const out = {};
|
|
379
|
+
for (const [kind, k] of Object.entries(KINDS)) {
|
|
380
|
+
const table = tables[kind];
|
|
381
|
+
const options = Object.values(table).map((p) => ({
|
|
382
|
+
id: p.id,
|
|
383
|
+
label: p.label || "",
|
|
384
|
+
module: "built-in",
|
|
385
|
+
builtin: true,
|
|
386
|
+
}));
|
|
387
|
+
const seen = new Set(options.map((o) => o.id));
|
|
388
|
+
const plugins = pluginsOf(k.type);
|
|
389
|
+
const count = {};
|
|
390
|
+
for (const p of plugins) count[p.id] = (count[p.id] || 0) + 1;
|
|
391
|
+
for (const p of plugins) {
|
|
392
|
+
if (typeof p.id !== "string" || seen.has(p.id)) continue;
|
|
393
|
+
seen.add(p.id);
|
|
394
|
+
const opt = {
|
|
395
|
+
id: p.id,
|
|
396
|
+
label: typeof p.label === "string" ? p.label : "",
|
|
397
|
+
module: typeof p.module === "string" ? p.module : "",
|
|
398
|
+
builtin: false,
|
|
399
|
+
};
|
|
400
|
+
if (count[p.id] > 1) {
|
|
401
|
+
opt.problem = "registered by more than one package — neither is used";
|
|
402
|
+
} else if (p.apiVersion !== API_VERSION) {
|
|
403
|
+
opt.problem = `apiVersion ${JSON.stringify(p.apiVersion)}, expected ${API_VERSION}`;
|
|
404
|
+
} else if (!PROVIDER_ID_RE.test(p.id)) {
|
|
405
|
+
opt.problem = "id cannot be selected (letters, digits, . _ - only)";
|
|
406
|
+
}
|
|
407
|
+
options.push(opt);
|
|
408
|
+
}
|
|
409
|
+
const { id, source } = current(kind);
|
|
410
|
+
const missing = !seen.has(id);
|
|
411
|
+
if (missing) {
|
|
412
|
+
options.push({ id, label: "", module: "", builtin: false, missing: true });
|
|
413
|
+
}
|
|
414
|
+
out[kind] = { selected: id, source, locked: source === "settings", missing, options };
|
|
415
|
+
}
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Choose the provider for `kind` from the editor and persist it. Only a
|
|
421
|
+
* provider that is registered and usable right now can be chosen.
|
|
422
|
+
*
|
|
423
|
+
* @param {string} kind "identity" | "css"
|
|
424
|
+
* @param {string} id
|
|
425
|
+
* @returns {boolean} true when the provider in force changed.
|
|
426
|
+
* @throws {ProviderSelectError}
|
|
427
|
+
*/
|
|
428
|
+
function select(kind, id) {
|
|
429
|
+
if (!Object.prototype.hasOwnProperty.call(KINDS, kind)) {
|
|
430
|
+
throw new ProviderSelectError("bad-kind", `unknown provider kind "${kind}"`);
|
|
431
|
+
}
|
|
432
|
+
if (typeof id !== "string" || !PROVIDER_ID_RE.test(id)) {
|
|
433
|
+
throw new ProviderSelectError("bad-id", "invalid provider id");
|
|
434
|
+
}
|
|
435
|
+
const before = current(kind);
|
|
436
|
+
if (before.source === "settings") {
|
|
437
|
+
throw new ProviderSelectError(
|
|
438
|
+
"locked",
|
|
439
|
+
`${kind} provider is set in settings.js (portalReact.${KINDS[kind].setting})`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
const { provider, error } = lookup(KINDS[kind].type, tables[kind], id);
|
|
443
|
+
if (!provider) throw new ProviderSelectError("unknown", error);
|
|
444
|
+
const prev = chosen[kind];
|
|
445
|
+
chosen[kind] = id;
|
|
446
|
+
try {
|
|
447
|
+
saveChoice();
|
|
448
|
+
} catch (e) {
|
|
449
|
+
if (prev === undefined) delete chosen[kind];
|
|
450
|
+
else chosen[kind] = prev;
|
|
451
|
+
throw e;
|
|
452
|
+
}
|
|
453
|
+
return before.id !== id;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Who is behind this request. Never rejects: every failure is anonymous.
|
|
458
|
+
*
|
|
459
|
+
* @param {Object} req HTTP request or WS upgrade request (Node IncomingMessage).
|
|
460
|
+
* @returns {Promise<Object|null>}
|
|
461
|
+
*/
|
|
462
|
+
async function resolveUser(req) {
|
|
463
|
+
const cur = current("identity");
|
|
464
|
+
const id = cur.id;
|
|
465
|
+
// Called only for portals with Portal Auth on — so this is someone who
|
|
466
|
+
// expects users and gets none.
|
|
467
|
+
if (cur.addonMissing) {
|
|
468
|
+
explainNone("identity", "Portal Auth is on but no identity provider is installed, so every " +
|
|
469
|
+
"session is anonymous. For users from x-portal-user-* headers install " +
|
|
470
|
+
"@aaqu/fromcubes-auth-headers, or choose a provider in Settings → Fromcubes.");
|
|
471
|
+
}
|
|
472
|
+
const { provider, error } = lookup(IDENTITY_TYPE, builtins.identity, id);
|
|
473
|
+
if (!provider) {
|
|
474
|
+
logOnce(error + " — every session is anonymous");
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
if (typeof provider.resolveUser !== "function") {
|
|
478
|
+
logOnce(`${IDENTITY_TYPE} provider "${id}" has no resolveUser() — every session is anonymous`);
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
let timer = null;
|
|
482
|
+
try {
|
|
483
|
+
const raw = await Promise.race([
|
|
484
|
+
Promise.resolve().then(() => provider.resolveUser(req)),
|
|
485
|
+
new Promise((_, reject) => {
|
|
486
|
+
timer = setTimeout(
|
|
487
|
+
() => reject(new Error(`timed out after ${identityTimeoutMs} ms`)),
|
|
488
|
+
identityTimeoutMs,
|
|
489
|
+
);
|
|
490
|
+
}),
|
|
491
|
+
]);
|
|
492
|
+
return sanitizeUser(raw);
|
|
493
|
+
} catch (e) {
|
|
494
|
+
RED.log.warn(`[portal-react] identity provider "${id}" failed: ${e.message}`);
|
|
495
|
+
return null;
|
|
496
|
+
} finally {
|
|
497
|
+
clearTimeout(timer);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Generate the stylesheet for a portal's source. Rejects on failure — the
|
|
503
|
+
* caller owns the `css-fail` status.
|
|
504
|
+
*
|
|
505
|
+
* @param {string} source
|
|
506
|
+
* @returns {Promise<{css: string}>}
|
|
507
|
+
*/
|
|
508
|
+
async function generateCss(source) {
|
|
509
|
+
const cur = current("css");
|
|
510
|
+
const id = cur.id;
|
|
511
|
+
if (cur.addonMissing) {
|
|
512
|
+
explainNone("css", "No CSS engine is installed, so portals are served without a stylesheet. " +
|
|
513
|
+
"For Tailwind CSS install @aaqu/fromcubes-tailwind.");
|
|
514
|
+
}
|
|
515
|
+
const { provider, error } = lookup(CSS_TYPE, builtins.css, id);
|
|
516
|
+
if (!provider) throw new Error(error);
|
|
517
|
+
if (typeof provider.generate !== "function") {
|
|
518
|
+
throw new Error(`${CSS_TYPE} provider "${id}" has no generate()`);
|
|
519
|
+
}
|
|
520
|
+
const out = await provider.generate(source);
|
|
521
|
+
if (!out || typeof out.css !== "string") {
|
|
522
|
+
throw new Error(`${CSS_TYPE} provider "${id}" returned no css string`);
|
|
523
|
+
}
|
|
524
|
+
return { css: out.css };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Class names the editor offers inside `className="…"` for the engine in
|
|
529
|
+
* force. Optional for plugins (`editor.classes()`); an engine without it,
|
|
530
|
+
* an unusable engine, or one that fails yields an empty list — completion
|
|
531
|
+
* just stays quiet, nothing else depends on it.
|
|
532
|
+
*
|
|
533
|
+
* @returns {Promise<{engine: string, classes: Array<string>}>}
|
|
534
|
+
*/
|
|
535
|
+
async function editorClasses() {
|
|
536
|
+
const engine = cssEngineId();
|
|
537
|
+
const { provider } = lookup(CSS_TYPE, builtins.css, engine);
|
|
538
|
+
const fn = provider && provider.editor && provider.editor.classes;
|
|
539
|
+
if (typeof fn !== "function") return { engine, classes: [] };
|
|
540
|
+
if (classCache.has(provider)) return { engine, classes: classCache.get(provider) };
|
|
541
|
+
let raw;
|
|
542
|
+
try {
|
|
543
|
+
raw = await fn.call(provider.editor);
|
|
544
|
+
} catch (e) {
|
|
545
|
+
RED.log.warn(`[portal-react] css engine "${engine}" editor.classes() failed: ${e.message}`);
|
|
546
|
+
return { engine, classes: [] };
|
|
547
|
+
}
|
|
548
|
+
const classes = [];
|
|
549
|
+
if (Array.isArray(raw)) {
|
|
550
|
+
const seen = new Set();
|
|
551
|
+
for (const c of raw) {
|
|
552
|
+
if (classes.length >= MAX_EDITOR_CLASSES) break;
|
|
553
|
+
if (typeof c !== "string" || !c || c.length > MAX_EDITOR_CLASS_LEN || /\s/.test(c)) continue;
|
|
554
|
+
if (seen.has(c)) continue;
|
|
555
|
+
seen.add(c);
|
|
556
|
+
classes.push(c);
|
|
557
|
+
}
|
|
558
|
+
} else {
|
|
559
|
+
RED.log.warn(`[portal-react] css engine "${engine}" editor.classes() did not return an array`);
|
|
560
|
+
}
|
|
561
|
+
classCache.set(provider, classes);
|
|
562
|
+
return { engine, classes };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return { resolveUser, generateCss, identityId, cssEngineId, describe, select, editorClasses };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
module.exports = {
|
|
569
|
+
createProviders,
|
|
570
|
+
sanitizeUser,
|
|
571
|
+
ProviderSelectError,
|
|
572
|
+
PROVIDER_ID_RE,
|
|
573
|
+
IDENTITY_TYPE,
|
|
574
|
+
CSS_TYPE,
|
|
575
|
+
API_VERSION,
|
|
576
|
+
DEFAULT_IDENTITY,
|
|
577
|
+
DEFAULT_CSS,
|
|
578
|
+
MAX_USER_STRING,
|
|
579
|
+
MAX_GROUPS,
|
|
580
|
+
MAX_EDITOR_CLASSES,
|
|
581
|
+
};
|