@cyprnet/node-red-contrib-uibuilder-formgen 0.4.13 → 0.5.1

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,482 @@
1
+ /**
2
+ * PortalSmith FormGen - uibuilder-formgen-v3 node (Vue 3 + Bootstrap 5)
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * Copyright (c) 2026 CyprNet Solutions, LLC
6
+ */
7
+
8
+ module.exports = function(RED) {
9
+ const path = require("path");
10
+ const fs = require("fs-extra");
11
+ const Mustache = require("mustache");
12
+ const crypto = require("crypto");
13
+ const http = require("http");
14
+ const https = require("https");
15
+ const licensing = require("../lib/licensing");
16
+
17
+ // Register admin endpoints once (used by the editor UI to show license status)
18
+ let _adminRegistered = false;
19
+ function registerAdminEndpoints() {
20
+ if (_adminRegistered) return;
21
+ _adminRegistered = true;
22
+ if (!RED.httpAdmin) return;
23
+
24
+ const guard =
25
+ RED.auth && typeof RED.auth.needsPermission === "function"
26
+ ? RED.auth.needsPermission("uibuilder-formgen.read")
27
+ : function (_req, _res, next) { next(); };
28
+
29
+ RED.httpAdmin.post("/portalsmith/license/validate", guard, function (req, res) {
30
+ try {
31
+ const body = req.body || {};
32
+ const licenseKey = String(body.licenseKey || "").trim();
33
+ const licenseConfigId = String(body.licenseConfigId || "").trim();
34
+
35
+ let resolvedKey = licenseKey;
36
+ if (!resolvedKey && licenseConfigId) {
37
+ const cfg = RED.nodes.getNode(licenseConfigId);
38
+ resolvedKey = String(cfg && cfg.credentials ? cfg.credentials.licenseKey : "").trim();
39
+ }
40
+
41
+ const result = licensing.resolveLicenseString(resolvedKey);
42
+ const verifier = licensing.getVerifierPublicKey();
43
+ res.json({
44
+ ok: true,
45
+ licensed: result.licensed,
46
+ tier: result.tier,
47
+ reason: result.reason,
48
+ displayName: result.display && result.display.displayName ? result.display.displayName : "",
49
+ licensedTo: result.display && result.display.licensedTo ? result.display.licensedTo : "",
50
+ expiresAt: result.display && result.display.expiresAt ? result.display.expiresAt : "",
51
+ features: result.features || {},
52
+ policy: result.policy || {},
53
+ verifier: {
54
+ source: verifier.source,
55
+ fingerprint: licensing.publicKeyFingerprint(verifier.pem),
56
+ }
57
+ });
58
+ } catch (e) {
59
+ res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) });
60
+ }
61
+ });
62
+ }
63
+ registerAdminEndpoints();
64
+
65
+ function isNonEmptyString(v) {
66
+ return typeof v === "string" && v.trim().length > 0;
67
+ }
68
+
69
+ function safeBasename(filename) {
70
+ return path.basename(String(filename || "").replace(/[/\\]+/g, "_"));
71
+ }
72
+
73
+ async function maybePrepareLogo({ logoPathRaw, logoAltRaw, srcDir }) {
74
+ const logoPath = String(logoPathRaw ?? "").trim();
75
+ const logoAlt = String(logoAltRaw ?? "Logo").trim() || "Logo";
76
+
77
+ if (!isNonEmptyString(logoPath)) {
78
+ return { logoUrl: "", logoAlt: logoAlt };
79
+ }
80
+
81
+ if (path.isAbsolute(logoPath)) {
82
+ const exists = await fs.pathExists(logoPath);
83
+ if (!exists) {
84
+ throw new Error(`logoPath does not exist: ${logoPath}`);
85
+ }
86
+
87
+ const imagesDir = path.join(srcDir, "images");
88
+ await fs.ensureDir(imagesDir);
89
+
90
+ const srcBase = safeBasename(logoPath);
91
+ const hash = crypto.createHash("sha1").update(logoPath).digest("hex").slice(0, 8);
92
+ const outName = `${hash}-${srcBase}`;
93
+ const outPath = path.join(imagesDir, outName);
94
+
95
+ await fs.copy(logoPath, outPath, { overwrite: true, errorOnExist: false });
96
+ return { logoUrl: `./images/${outName}`, logoAlt };
97
+ }
98
+
99
+ const trimmed = logoPath.replace(/^\.?\/*/, "");
100
+ const fileOnly = safeBasename(trimmed);
101
+ return { logoUrl: `./images/${fileOnly}`, logoAlt };
102
+ }
103
+
104
+ async function httpRequestJson({ url, method, headers, body, insecureTls, timeoutMs }) {
105
+ const urlObj = new URL(url);
106
+ const isHttps = urlObj.protocol === "https:";
107
+ const lib = isHttps ? https : http;
108
+
109
+ const reqHeaders = Object.assign({}, headers || {});
110
+ let bodyStr = "";
111
+ if (body !== undefined) {
112
+ bodyStr = typeof body === "string" ? body : JSON.stringify(body);
113
+ if (!reqHeaders["Content-Type"] && !reqHeaders["content-type"]) {
114
+ reqHeaders["Content-Type"] = "application/json";
115
+ }
116
+ reqHeaders["Content-Length"] = Buffer.byteLength(bodyStr);
117
+ }
118
+
119
+ const agent = isHttps
120
+ ? new https.Agent({ rejectUnauthorized: insecureTls ? false : true })
121
+ : undefined;
122
+
123
+ const options = {
124
+ method: method || "POST",
125
+ protocol: urlObj.protocol,
126
+ hostname: urlObj.hostname,
127
+ port: urlObj.port || (isHttps ? 443 : 80),
128
+ path: urlObj.pathname + urlObj.search,
129
+ headers: reqHeaders,
130
+ agent,
131
+ timeout: Number(timeoutMs || 15000),
132
+ };
133
+
134
+ return await new Promise((resolve, reject) => {
135
+ const req = lib.request(options, (res) => {
136
+ const chunks = [];
137
+ res.on("data", (d) => chunks.push(d));
138
+ res.on("end", () => {
139
+ const text = Buffer.concat(chunks).toString("utf8");
140
+ const ct = String(res.headers["content-type"] || "").toLowerCase();
141
+ let json = null;
142
+ if (ct.includes("application/json")) {
143
+ try { json = JSON.parse(text); } catch (e) { /* ignore */ }
144
+ }
145
+ resolve({
146
+ status: res.statusCode || 0,
147
+ headers: res.headers,
148
+ text,
149
+ json,
150
+ });
151
+ });
152
+ });
153
+
154
+ req.on("timeout", () => {
155
+ req.destroy(new Error("Request timeout"));
156
+ });
157
+ req.on("error", reject);
158
+
159
+ if (bodyStr) req.write(bodyStr);
160
+ req.end();
161
+ });
162
+ }
163
+
164
+ function resolveUibuilderPaths({ RED, config, msg, instanceName }) {
165
+ const userDir = RED.settings.userDir || path.join(process.env.HOME || "", ".node-red");
166
+
167
+ const projectName = String(msg.options?.projectName ?? config.projectName ?? "").trim();
168
+ const uibRootDir = String(msg.options?.uibRootDir ?? msg.options?.uibRoot ?? config.uibRootDir ?? "").trim();
169
+ const instanceRootDir = String(msg.options?.instanceRootDir ?? config.instanceRootDir ?? "").trim();
170
+
171
+ if (isNonEmptyString(instanceRootDir)) {
172
+ if (!path.isAbsolute(instanceRootDir)) {
173
+ throw new Error(`instanceRootDir must be an absolute path. Got: ${instanceRootDir}`);
174
+ }
175
+ return { userDir, uibRoot: path.dirname(instanceRootDir), instRoot: instanceRootDir, srcDir: path.join(instanceRootDir, "src") };
176
+ }
177
+
178
+ let uibRoot;
179
+ if (isNonEmptyString(uibRootDir)) {
180
+ if (!path.isAbsolute(uibRootDir)) {
181
+ throw new Error(`uibRootDir must be an absolute path. Got: ${uibRootDir}`);
182
+ }
183
+ uibRoot = uibRootDir;
184
+ } else if (isNonEmptyString(projectName)) {
185
+ uibRoot = path.join(userDir, "projects", projectName, "uibuilder");
186
+ } else {
187
+ uibRoot = path.join(userDir, "uibuilder");
188
+ }
189
+
190
+ const instRoot = path.join(uibRoot, instanceName);
191
+ return { userDir, uibRoot, instRoot, srcDir: path.join(instRoot, "src") };
192
+ }
193
+
194
+ function validateSchema(schema) {
195
+ if (!schema.schemaVersion) {
196
+ throw new Error("Schema missing required 'schemaVersion' field");
197
+ }
198
+ if (!schema.sections || !Array.isArray(schema.sections)) {
199
+ throw new Error("Schema missing required 'sections' array");
200
+ }
201
+ if (schema.sections.length === 0) {
202
+ throw new Error("Schema must have at least one section");
203
+ }
204
+
205
+ const validTypes = [
206
+ "text",
207
+ "textarea",
208
+ "number",
209
+ "select",
210
+ "checkbox",
211
+ "radio",
212
+ "date",
213
+ "keyvalue",
214
+ ];
215
+
216
+ schema.sections.forEach((section, idx) => {
217
+ if (!section.fields || !Array.isArray(section.fields)) {
218
+ throw new Error(`Section ${idx + 1} missing required 'fields' array`);
219
+ }
220
+ section.fields.forEach((field) => {
221
+ if (!field.id) throw new Error(`Section ${idx + 1} has a field missing required 'id'`);
222
+ if (!field.type) throw new Error(`Section ${idx + 1}, field ${field.id} missing required 'type'`);
223
+ if (!validTypes.includes(field.type)) {
224
+ throw new Error(`Section ${idx + 1}, field ${field.id} has invalid type: ${field.type}`);
225
+ }
226
+ if ((field.type === "select" || field.type === "radio") && field.options && !Array.isArray(field.options)) {
227
+ throw new Error(`Section ${idx + 1}, field ${field.id} options must be an array`);
228
+ }
229
+ if (field.type === "keyvalue" && field.keyvalueMode === "pairs") {
230
+ if (field.pairs && !Array.isArray(field.pairs)) {
231
+ throw new Error(`Section ${idx + 1}, field ${field.id} pairs must be an array`);
232
+ }
233
+ }
234
+ });
235
+ });
236
+ }
237
+
238
+ function calculateSchemaSummary(schema) {
239
+ const formId = schema.formId || "unknown";
240
+ const title = schema.title || "PortalSmith Form";
241
+ let sectionCount = 0;
242
+ let fieldCount = 0;
243
+ if (schema.sections && Array.isArray(schema.sections)) {
244
+ sectionCount = schema.sections.length;
245
+ schema.sections.forEach(section => {
246
+ if (section.fields && Array.isArray(section.fields)) {
247
+ fieldCount += section.fields.length;
248
+ }
249
+ });
250
+ }
251
+ return { formId, title, sectionCount, fieldCount };
252
+ }
253
+
254
+ function UibuilderFormGenV3Node(config) {
255
+ RED.nodes.createNode(this, config);
256
+ const node = this;
257
+ const licenseConfigNode = config.licenseConfig ? RED.nodes.getNode(config.licenseConfig) : null;
258
+
259
+ node.on("input", async function(msg, send, done) {
260
+ try {
261
+ const payload = msg && (msg.payload ?? msg);
262
+ if (payload && typeof payload === "object" && payload.type === "submit") {
263
+ const formId = payload.formId || "";
264
+ const submitPayload = payload.payload ?? payload.data ?? payload;
265
+
266
+ const apiUrl = String(msg.options?.apiUrl ?? config.apiUrl ?? "").trim();
267
+ const apiMethod = String(msg.options?.apiMethod ?? config.apiMethod ?? "POST").trim().toUpperCase();
268
+ const apiHeadersJson = String(msg.options?.apiHeadersJson ?? config.apiHeadersJson ?? "").trim();
269
+ const apiInsecureTls = (msg.options?.apiInsecureTls ?? config.apiInsecureTls) === true;
270
+ const apiTimeoutMs = Number(msg.options?.apiTimeoutMs ?? config.apiTimeoutMs ?? 15000);
271
+
272
+ let apiHeaders = {};
273
+ if (isNonEmptyString(apiHeadersJson)) {
274
+ try { apiHeaders = JSON.parse(apiHeadersJson); } catch (e) { throw new Error("apiHeadersJson must be valid JSON"); }
275
+ }
276
+
277
+ if (!isNonEmptyString(apiUrl)) {
278
+ msg.payload = {
279
+ type: "submit:ok",
280
+ formId,
281
+ result: { passthrough: true, payload: submitPayload },
282
+ status: 0,
283
+ };
284
+ send(msg);
285
+ done();
286
+ return;
287
+ }
288
+
289
+ const apiBody = { formId, payload: submitPayload, meta: payload.meta || {} };
290
+ const resp = await httpRequestJson({
291
+ url: apiUrl,
292
+ method: apiMethod,
293
+ headers: apiHeaders,
294
+ body: apiBody,
295
+ insecureTls: apiInsecureTls,
296
+ timeoutMs: apiTimeoutMs,
297
+ });
298
+
299
+ msg.payload = {
300
+ type: resp.status >= 200 && resp.status < 300 ? "submit:ok" : "submit:error",
301
+ formId,
302
+ status: resp.status,
303
+ result: resp.json !== null ? resp.json : resp.text,
304
+ responseHeaders: resp.headers,
305
+ };
306
+ send(msg);
307
+ done();
308
+ return;
309
+ }
310
+
311
+ // Schema
312
+ let schema = msg.schema;
313
+ if (!schema || typeof schema !== "object") schema = msg.payload;
314
+ if (typeof schema === "string") {
315
+ try { schema = JSON.parse(schema); } catch (e) { throw new Error("msg.schema or msg.payload must be a JSON object or valid JSON string"); }
316
+ }
317
+ if (!schema || typeof schema !== "object") throw new Error("Missing required msg.schema or msg.payload object");
318
+ validateSchema(schema);
319
+
320
+ const instanceName = String(msg.uibuilder ?? msg.options?.instance ?? config.instanceName ?? "formgen").trim();
321
+ if (!/^[a-zA-Z0-9._-]+$/.test(instanceName)) {
322
+ throw new Error("Invalid instanceName; use letters/numbers/._- only");
323
+ }
324
+
325
+ const overwrite = (msg.options?.overwrite ?? config.overwrite) !== false;
326
+ const storageMode = msg.options?.storageMode ?? config.storageMode ?? "file";
327
+ const exportFormats = msg.options?.exportFormats ??
328
+ (config.exportFormats ? JSON.parse(config.exportFormats) : ["json", "csv", "html"]);
329
+ const uiTitle = msg.options?.uiTitle ?? schema.title ?? "PortalSmith Form";
330
+ const themeMode = String(msg.options?.themeMode ?? config.themeMode ?? "auto").trim().toLowerCase();
331
+ if (!["auto", "light", "dark"].includes(themeMode)) {
332
+ throw new Error("themeMode must be 'auto', 'light', or 'dark'");
333
+ }
334
+ if (!["file", "localstorage"].includes(storageMode)) {
335
+ throw new Error("storageMode must be 'file' or 'localstorage'");
336
+ }
337
+
338
+ const validFormats = ["json", "csv", "html"];
339
+ const invalidFormats = exportFormats.filter(f => !validFormats.includes(f));
340
+ if (invalidFormats.length > 0) {
341
+ throw new Error(`Invalid exportFormats: ${invalidFormats.join(", ")}`);
342
+ }
343
+
344
+ const { userDir, uibRoot, instRoot, srcDir } = resolveUibuilderPaths({ RED, config, msg, instanceName });
345
+ await fs.ensureDir(srcDir);
346
+
347
+ const license = licensing.resolveLicense({ node, RED, licenseConfigNode });
348
+ const licensePublic = {
349
+ licensed: Boolean(license.licensed),
350
+ tier: license.tier || "FREE",
351
+ watermark: Boolean(license.policy && license.policy.watermark),
352
+ brandingLocked: Boolean(license.policy && license.policy.brandingLocked),
353
+ displayName: String(license.display && license.display.displayName ? license.display.displayName : ""),
354
+ reason: String(license.reason || "missing"),
355
+ brandingAttempted: false,
356
+ };
357
+
358
+ const logoRequested = isNonEmptyString(msg.options?.logoPath ?? config.logoPath ?? "");
359
+ let logoPathRaw = msg.options?.logoPath ?? config.logoPath ?? "";
360
+ const logoAltRaw = msg.options?.logoAlt ?? config.logoAlt ?? "Logo";
361
+ if (isNonEmptyString(logoPathRaw) && licensePublic.brandingLocked) {
362
+ node.warn("PortalSmith FormGen: custom logo is disabled in Free mode (watermarked). Using default branding.");
363
+ licensePublic.brandingAttempted = Boolean(logoRequested);
364
+ logoPathRaw = "";
365
+ }
366
+ const { logoUrl, logoAlt } = await maybePrepareLogo({ logoPathRaw, logoAltRaw, srcDir });
367
+
368
+ const submitMode = String(msg.options?.submitMode ?? config.submitMode ?? "uibuilder").trim().toLowerCase();
369
+ const submitUrl = String(msg.options?.submitUrl ?? config.submitUrl ?? "").trim();
370
+ const submitHeadersJson = String(msg.options?.submitHeadersJson ?? config.submitHeadersJson ?? "").trim();
371
+ if (!["uibuilder", "http"].includes(submitMode)) {
372
+ throw new Error("submitMode must be 'uibuilder' or 'http'");
373
+ }
374
+ if (submitMode === "http" && !isNonEmptyString(submitUrl)) {
375
+ throw new Error("submitUrl is required when submitMode is 'http'");
376
+ }
377
+ if (isNonEmptyString(submitHeadersJson)) {
378
+ try { JSON.parse(submitHeadersJson); } catch (e) { throw new Error("submitHeadersJson must be valid JSON"); }
379
+ }
380
+
381
+ const targetHtml = path.join(srcDir, "index.html");
382
+ const targetJs = path.join(srcDir, "index.js");
383
+ const targetSchema = path.join(srcDir, "form.schema.json");
384
+ const targetRuntime = path.join(instRoot, "portalsmith.runtime.json");
385
+
386
+ if (!overwrite) {
387
+ const existingFiles = [targetHtml, targetJs, targetSchema, targetRuntime];
388
+ for (const f of existingFiles) {
389
+ if (await fs.pathExists(f)) throw new Error(`File exists and overwrite=false: ${f}`);
390
+ }
391
+ }
392
+
393
+ const schemaSummary = calculateSchemaSummary(schema);
394
+ const templatesDir = path.join(__dirname, "..", "templates");
395
+ const htmlTemplate = await fs.readFile(path.join(templatesDir, "index.v3.html.mustache"), "utf8");
396
+ const jsTemplate = await fs.readFile(path.join(templatesDir, "index.v3.js.mustache"), "utf8");
397
+
398
+ const baseUrl = config.uibuilderUrl || "/uibuilder";
399
+ const htmlViewModel = {
400
+ title: uiTitle,
401
+ description: schema.description || "",
402
+ baseUrl: baseUrl,
403
+ themeMode: themeMode,
404
+ logoUrl: logoUrl,
405
+ logoAlt: logoAlt,
406
+ licensed: licensePublic.licensed
407
+ };
408
+
409
+ const jsViewModel = {
410
+ timestamp: new Date().toISOString(),
411
+ instanceName: instanceName,
412
+ formId: schema.formId || schemaSummary.formId,
413
+ storageMode: storageMode,
414
+ exportFormatsJson: JSON.stringify(exportFormats),
415
+ baseUrl: baseUrl,
416
+ themeMode: themeMode,
417
+ submitMode: submitMode,
418
+ submitUrl: submitUrl,
419
+ submitHeadersJson: submitHeadersJson,
420
+ licenseJson: JSON.stringify(licensePublic)
421
+ };
422
+
423
+ const oldTags = Mustache.tags;
424
+ Mustache.tags = ['[[', ']]'];
425
+ let renderedHtml = Mustache.render(htmlTemplate, htmlViewModel);
426
+ let renderedJs = Mustache.render(jsTemplate, jsViewModel);
427
+ Mustache.tags = oldTags;
428
+
429
+ renderedJs = renderedJs.replace('__EXPORT_FORMATS_JSON__', JSON.stringify(exportFormats));
430
+ renderedJs = renderedJs.replace('__LICENSE_JSON__', jsViewModel.licenseJson);
431
+
432
+ await fs.writeFile(targetHtml, renderedHtml, "utf8");
433
+ await fs.writeFile(targetJs, renderedJs, "utf8");
434
+ await fs.writeJson(targetSchema, schema, { spaces: 2 });
435
+
436
+ const runtimeData = {
437
+ generatorVersion: "0.5.0",
438
+ generatorNode: "uibuilder-formgen-v3",
439
+ timestamp: new Date().toISOString(),
440
+ instanceName: instanceName,
441
+ storageMode: storageMode,
442
+ exportFormats: exportFormats,
443
+ themeMode: themeMode,
444
+ logoUrl: logoUrl || "",
445
+ logoAlt: logoAlt || "",
446
+ submitMode: submitMode,
447
+ submitUrl: submitUrl,
448
+ license: licensePublic,
449
+ schemaSummary: schemaSummary
450
+ };
451
+ await fs.writeJson(targetRuntime, runtimeData, { spaces: 2 });
452
+
453
+ msg.payload = {
454
+ status: "generated",
455
+ instance: instanceName,
456
+ uibRoot: uibRoot,
457
+ path: instRoot,
458
+ srcPath: srcDir,
459
+ userDir: userDir,
460
+ url: `${baseUrl}/${instanceName}/`,
461
+ files: ["src/index.html", "src/index.js", "src/form.schema.json", "portalsmith.runtime.json"],
462
+ schemaSummary: schemaSummary,
463
+ portalRuntime: "vue3"
464
+ };
465
+
466
+ send(msg);
467
+ done();
468
+ } catch (err) {
469
+ node.error(err, msg);
470
+ done(err);
471
+ }
472
+ });
473
+ }
474
+
475
+ RED.nodes.registerType("uibuilder-formgen-v3", UibuilderFormGenV3Node, {
476
+ credentials: {
477
+ licenseKey: { type: "password" },
478
+ },
479
+ });
480
+ };
481
+
482
+
@@ -463,7 +463,7 @@ module.exports = function(RED) {
463
463
 
464
464
  // Write runtime metadata
465
465
  const runtimeData = {
466
- generatorVersion: "0.4.12",
466
+ generatorVersion: "0.5.0",
467
467
  timestamp: new Date().toISOString(),
468
468
  instanceName: instanceName,
469
469
  storageMode: storageMode,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyprnet/node-red-contrib-uibuilder-formgen",
3
- "version": "0.4.13",
3
+ "version": "0.5.1",
4
4
  "description": "PortalSmith: Generate schema-driven uibuilder form portals from JSON",
5
5
  "keywords": [
6
6
  "node-red",
@@ -41,6 +41,7 @@
41
41
  "node-red": {
42
42
  "nodes": {
43
43
  "uibuilder-formgen": "nodes/uibuilder-formgen.js",
44
+ "uibuilder-formgen-v3": "nodes/uibuilder-formgen-v3.js",
44
45
  "portalsmith-license": "nodes/portalsmith-license.js"
45
46
  }
46
47
  }