@saltcorn/server 1.0.0-beta.13 → 1.0.0-beta.15

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/app.js CHANGED
@@ -447,6 +447,9 @@ Sitemap: ${base}sitemap.xml
447
447
  app.get("*", function (req, res) {
448
448
  res.status(404).sendWrap(req.__("Not found"), h1(req.__("Page not found")));
449
449
  });
450
+
451
+ //prevent prototype pollution
452
+ delete Object.prototype.__proto__;
450
453
  return app;
451
454
  };
452
455
  module.exports = getApp;
package/load_plugins.js CHANGED
@@ -19,17 +19,46 @@ const {
19
19
  resolveLatest,
20
20
  } = require("@saltcorn/plugins-loader/stable_versioning");
21
21
 
22
+ const isFixedPlugin = (plugin) =>
23
+ plugin.location === "@saltcorn/sbadmin2" ||
24
+ plugin.location === "@saltcorn/base-plugin";
25
+
26
+ /**
27
+ * return the cached engine infos or fetch them from npm and update the cache
28
+ * @param plugin plugin to load
29
+ */
30
+ const getEngineInfos = async (plugin, forceFetch) => {
31
+ const rootState = getRootState();
32
+ const cached = rootState.getConfig("engines_cache", {}) || {};
33
+ if (cached[plugin.location] && !forceFetch) {
34
+ return cached[plugin.location];
35
+ } else {
36
+ getState().log(5, `Fetching versions for '${plugin.location}'`);
37
+ const pkgInfo = await npmFetch.json(
38
+ `https://registry.npmjs.org/${plugin.location}`
39
+ );
40
+ const versions = pkgInfo.versions;
41
+ const newCached = {};
42
+ for (const [k, v] of Object.entries(versions)) {
43
+ newCached[k] = v.engines?.saltcorn
44
+ ? { engines: { saltcorn: v.engines.saltcorn } }
45
+ : {};
46
+ }
47
+ cached[plugin.location] = newCached;
48
+ await rootState.setConfig("engines_cache", { ...cached });
49
+ return newCached;
50
+ }
51
+ };
52
+
22
53
  /**
23
54
  * checks the saltcorn engine property and changes the plugin version if necessary
24
55
  * @param plugin plugin to load
25
56
  */
26
- const ensurePluginSupport = async (plugin) => {
27
- const pkgInfo = await npmFetch.json(
28
- `https://registry.npmjs.org/${plugin.location}`
29
- );
57
+ const ensurePluginSupport = async (plugin, forceFetch) => {
58
+ const versions = await getEngineInfos(plugin, forceFetch);
30
59
  const supported = supportedVersion(
31
60
  plugin.version || "latest",
32
- pkgInfo.versions,
61
+ versions,
33
62
  packagejson.version
34
63
  );
35
64
  if (!supported)
@@ -38,8 +67,7 @@ const ensurePluginSupport = async (plugin) => {
38
67
  );
39
68
  else if (
40
69
  supported !== plugin.version ||
41
- (plugin.version === "latest" &&
42
- supported !== resolveLatest(pkgInfo.versions))
70
+ (plugin.version === "latest" && supported !== resolveLatest(versions))
43
71
  )
44
72
  plugin.version = supported;
45
73
  };
@@ -50,10 +78,10 @@ const ensurePluginSupport = async (plugin) => {
50
78
  * @param plugin - plugin to load
51
79
  * @param force - force flag
52
80
  */
53
- const loadPlugin = async (plugin, force) => {
54
- if (plugin.source === "npm" && isRoot()) {
81
+ const loadPlugin = async (plugin, force, forceFetch) => {
82
+ if (plugin.source === "npm" && !isFixedPlugin(plugin)) {
55
83
  try {
56
- await ensurePluginSupport(plugin);
84
+ await ensurePluginSupport(plugin, forceFetch);
57
85
  } catch (e) {
58
86
  console.log(
59
87
  `Warning: Unable to find a supported version for '${plugin.location}' Continuing with the installed version`
@@ -145,7 +173,7 @@ const loadAllPlugins = async (force) => {
145
173
  }
146
174
  }
147
175
  await getState().refreshUserLayouts();
148
- await getState().refresh(true);
176
+ await getState().refresh(true, true);
149
177
  if (!isRoot()) reloadAuthFromRoot();
150
178
  };
151
179
 
@@ -279,6 +307,6 @@ module.exports = {
279
307
  loadAllPlugins,
280
308
  loadPlugin,
281
309
  requirePlugin,
282
- supportedVersion,
310
+ getEngineInfos,
283
311
  ensurePluginSupport,
284
312
  };
package/locales/en.json CHANGED
@@ -1468,5 +1468,10 @@
1468
1468
  "Time to run": "Time to run",
1469
1469
  "Mobile": "Mobile",
1470
1470
  "Plain password trigger row": "Plain password trigger row",
1471
- "Send plaintext password changes to Users table triggers (Insert, Update and Validate).": "Send plaintext password changes to Users table triggers (Insert, Update and Validate)."
1472
- }
1471
+ "Send plaintext password changes to Users table triggers (Insert, Update and Validate).": "Send plaintext password changes to Users table triggers (Insert, Update and Validate).",
1472
+ "Minimum user role required to create a new tenant<div class=\"alert alert-danger fst-normal\" role=\"alert\" data-show-if=\"showIfFormulaInputs($('select[name=role_to_create_tenant]'), '+role_to_create_tenant>1')\">Giving non-trusted users access to create tenants is a security risk and not recommended.</div>": "Minimum user role required to create a new tenant<div class=\"alert alert-danger fst-normal\" role=\"alert\" data-show-if=\"showIfFormulaInputs($('select[name=role_to_create_tenant]'), '+role_to_create_tenant>1')\">Giving non-trusted users access to create tenants is a security risk and not recommended.</div>",
1473
+ "Select tag": "Select tag",
1474
+ "Invalid build directory path": "Invalid build directory path",
1475
+ "Invalid build directory name": "Invalid build directory name",
1476
+ "clean node_modules": "clean node_modules"
1477
+ }
package/package.json CHANGED
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "name": "@saltcorn/server",
3
- "version": "1.0.0-beta.13",
3
+ "version": "1.0.0-beta.15",
4
4
  "description": "Server app for Saltcorn, open-source no-code platform",
5
5
  "homepage": "https://saltcorn.com",
6
6
  "main": "index.js",
7
7
  "license": "MIT",
8
8
  "dependencies": {
9
9
  "@aws-sdk/client-s3": "^3.451.0",
10
- "@saltcorn/base-plugin": "1.0.0-beta.13",
11
- "@saltcorn/builder": "1.0.0-beta.13",
12
- "@saltcorn/data": "1.0.0-beta.13",
13
- "@saltcorn/admin-models": "1.0.0-beta.13",
14
- "@saltcorn/filemanager": "1.0.0-beta.13",
15
- "@saltcorn/markup": "1.0.0-beta.13",
16
- "@saltcorn/plugins-loader": "1.0.0-beta.13",
17
- "@saltcorn/sbadmin2": "1.0.0-beta.13",
10
+ "@saltcorn/base-plugin": "1.0.0-beta.15",
11
+ "@saltcorn/builder": "1.0.0-beta.15",
12
+ "@saltcorn/data": "1.0.0-beta.15",
13
+ "@saltcorn/admin-models": "1.0.0-beta.15",
14
+ "@saltcorn/filemanager": "1.0.0-beta.15",
15
+ "@saltcorn/markup": "1.0.0-beta.15",
16
+ "@saltcorn/plugins-loader": "1.0.0-beta.15",
17
+ "@saltcorn/sbadmin2": "1.0.0-beta.15",
18
18
  "@socket.io/cluster-adapter": "^0.2.1",
19
19
  "@socket.io/sticky": "^1.0.1",
20
20
  "adm-zip": "0.5.10",
@@ -18,7 +18,7 @@ function monospace_block_click(e) {
18
18
  function copy_monospace_block(e) {
19
19
  let e1 = $(e).next("pre");
20
20
  let e2 = $(e1).next("pre");
21
- if (!e2.length) return navigator.clipboard.writeText($(el).text());
21
+ if (!e2.length) return navigator.clipboard.writeText($(e1).text());
22
22
  const e1t = e1.text();
23
23
  const e2t = e2.text();
24
24
  if (e1t.length > e2t.length) return navigator.clipboard.writeText(e1t);
@@ -184,8 +184,21 @@ function apply_showif() {
184
184
  var current = e.attr("data-selected") || e.val();
185
185
  //console.log({ field: e.attr("name"), target: data[0], val, current });
186
186
  e.empty();
187
+ //TODO clean repetition in following cose
187
188
  (options || []).forEach((o) => {
188
- if (
189
+ if (o && o.optgroup) {
190
+ const opts = o.options
191
+ .map(
192
+ (innero) =>
193
+ `<option ${
194
+ `${current}` === `${innero.value || innero}` ? "selected " : ""
195
+ }value="${innero.value || innero}">${
196
+ innero.label || innero
197
+ }</option>`
198
+ )
199
+ .join("");
200
+ e.append($(`<optgroup label="${o.label}">` + opts + "</optgroup>"));
201
+ } else if (
189
202
  !(o && typeof o.label !== "undefined" && typeof o.value !== "undefined")
190
203
  ) {
191
204
  if (`${current}` === `${o}`)
@@ -225,8 +225,10 @@ function ajax_done(res, viewname) {
225
225
  function spin_action_link(e) {
226
226
  const $e = $(e);
227
227
  const width = $e.width();
228
+ const height = $e.height();
229
+
228
230
  $e.attr("data-innerhtml-prespin", $e.html());
229
- $e.html('<i class="fas fa-spinner fa-spin"></i>').width(width);
231
+ $e.html('<i class="fas fa-spinner fa-spin"></i>').width(width).height(height);
230
232
  }
231
233
 
232
234
  function reset_spinners() {
@@ -870,7 +872,7 @@ function build_mobile_app(button) {
870
872
 
871
873
  if (
872
874
  params.useDocker &&
873
- !cordovaBuilderAvailable &&
875
+ !window.cordovaBuilderAvailable &&
874
876
  !confirm(
875
877
  "Docker is selected but the Cordova builder seems not to be installed. " +
876
878
  "Do you really want to continue?"
@@ -878,6 +880,21 @@ function build_mobile_app(button) {
878
880
  ) {
879
881
  return;
880
882
  }
883
+
884
+ const notSupportedPlugins = params.includedPlugins.filter(
885
+ (plugin) => !window.pluginsReadyForMobile.includes(plugin)
886
+ );
887
+ if (
888
+ notSupportedPlugins.length > 0 &&
889
+ !confirm(
890
+ `It seems that the plugins '${notSupportedPlugins.join(
891
+ ", "
892
+ )}' are not ready for mobile. Do you really want to continue?`
893
+ )
894
+ ) {
895
+ return;
896
+ }
897
+
881
898
  ajax_post("/admin/build-mobile-app", {
882
899
  data: params,
883
900
  success: (data) => {
@@ -949,8 +966,8 @@ function check_cordova_builder() {
949
966
  $.ajax("/admin/mobile-app/check-cordova-builder", {
950
967
  type: "GET",
951
968
  success: function (res) {
952
- cordovaBuilderAvailable = !!res.installed;
953
- if (cordovaBuilderAvailable) {
969
+ window.cordovaBuilderAvailable = !!res.installed;
970
+ if (window.cordovaBuilderAvailable) {
954
971
  $("#dockerBuilderStatusId").html(
955
972
  `<span>
956
973
  installed<i class="ps-2 fas fa-check text-success"></i>
@@ -1044,7 +1061,7 @@ function toggle_tbl_sync() {
1044
1061
  function toggle_android_platform() {
1045
1062
  if ($("#androidCheckboxId")[0].checked === true) {
1046
1063
  $("#dockerCheckboxId").attr("hidden", false);
1047
- $("#dockerCheckboxId").attr("checked", cordovaBuilderAvailable);
1064
+ $("#dockerCheckboxId").attr("checked", window.cordovaBuilderAvailable);
1048
1065
  $("#dockerLabelId").removeClass("d-none");
1049
1066
  } else {
1050
1067
  $("#dockerCheckboxId").attr("hidden", true);
package/routes/actions.js CHANGED
@@ -57,21 +57,6 @@ const {
57
57
  blocklyToolbox,
58
58
  } = require("../markup/blockly.js");
59
59
 
60
- /**
61
- * @returns {Promise<object>}
62
- */
63
- const getActions = async () => {
64
- return Object.entries(getState().actions).map(([k, v]) => {
65
- const hasConfig = !!v.configFields;
66
- const requireRow = !!v.requireRow;
67
- return {
68
- name: k,
69
- hasConfig,
70
- requireRow,
71
- };
72
- });
73
- };
74
-
75
60
  /**
76
61
  * Show list of Actions (Triggers) (HTTP GET)
77
62
  * @name get
@@ -96,7 +81,7 @@ router.get(
96
81
  triggers = triggers.filter((t) => tagged_trigger_ids.has(t.id));
97
82
  filterOnTag = await Tag.findOne({ id: +req.query._tag });
98
83
  }
99
- const actions = await getActions();
84
+ const actions = Trigger.abbreviated_actions;
100
85
  send_events_page({
101
86
  res,
102
87
  req,
@@ -156,7 +141,7 @@ const triggerForm = async (req, trigger) => {
156
141
  value: r.id,
157
142
  label: r.role,
158
143
  }));
159
- const actions = await getActions();
144
+ const actions = Trigger.abbreviated_actions;
160
145
  const tables = await Table.find({});
161
146
  let id;
162
147
  let form_action;
@@ -168,14 +153,13 @@ const triggerForm = async (req, trigger) => {
168
153
  const hasChannel = Object.entries(getState().eventTypes)
169
154
  .filter(([k, v]) => v.hasChannel)
170
155
  .map(([k, v]) => k);
171
- const allActions = actions.map((t) => t.name);
172
- allActions.push("Multi-step action");
156
+
157
+ const allActions = Trigger.action_options({ notRequireRow: false });
173
158
  const table_triggers = ["Insert", "Update", "Delete", "Validate"];
174
159
  const action_options = {};
175
- const actionsNotRequiringRow = actions
176
- .filter((a) => !a.requireRow)
177
- .map((t) => t.name);
178
- actionsNotRequiringRow.push("Multi-step action");
160
+ const actionsNotRequiringRow = Trigger.action_options({
161
+ notRequireRow: true,
162
+ });
179
163
 
180
164
  Trigger.when_options.forEach((t) => {
181
165
  if (table_triggers.includes(t)) action_options[t] = allActions;
package/routes/admin.js CHANGED
@@ -673,7 +673,10 @@ router.get(
673
673
  const backup_file_prefix = getState().getConfig("backup_file_prefix");
674
674
  if (
675
675
  !isRoot ||
676
- !(filename.startsWith(backup_file_prefix) && filename.endsWith(".zip"))
676
+ !(
677
+ path.resolve(filename).startsWith(backup_file_prefix) &&
678
+ filename.endsWith(".zip")
679
+ )
677
680
  ) {
678
681
  res.redirect("/admin/backup");
679
682
  return;
@@ -1278,6 +1281,7 @@ router.get(
1278
1281
  throw new Error(req.__("Unable to fetch versions"));
1279
1282
  const versions = Object.keys(pkgInfo.versions);
1280
1283
  if (versions.length === 0) throw new Error(req.__("No versions found"));
1284
+ const tags = pkgInfo["dist-tags"] || {};
1281
1285
  res.set("Page-Title", req.__("%s versions", "Saltcorn"));
1282
1286
  let selected = packagejson.version;
1283
1287
  res.send(
@@ -1287,6 +1291,7 @@ router.get(
1287
1291
  method: "post",
1288
1292
  },
1289
1293
  input({ type: "hidden", name: "_csrf", value: req.csrfToken() }),
1294
+ // version select
1290
1295
  div(
1291
1296
  { class: "form-group" },
1292
1297
  label(
@@ -1312,6 +1317,54 @@ router.get(
1312
1317
  )
1313
1318
  )
1314
1319
  ),
1320
+ // tag select
1321
+ div(
1322
+ { class: "form-group" },
1323
+ label(
1324
+ {
1325
+ for: "tag_select",
1326
+ class: "form-label fw-bold",
1327
+ },
1328
+ req.__("Tags")
1329
+ ),
1330
+ select(
1331
+ {
1332
+ id: "tag_select",
1333
+ class: "form-control form-select",
1334
+ },
1335
+ option({
1336
+ id: "empty_opt",
1337
+ value: "",
1338
+ label: req.__("Select tag"),
1339
+ selected: true,
1340
+ }),
1341
+ Object.keys(tags).map((tag) =>
1342
+ option({
1343
+ id: `${tag}_opt`,
1344
+ value: tags[tag],
1345
+ label: `${tag} (${tags[tag]})`,
1346
+ })
1347
+ )
1348
+ )
1349
+ ),
1350
+ // deep clean checkbox
1351
+ div(
1352
+ { class: "form-group" },
1353
+ input({
1354
+ id: "deep_clean",
1355
+ class: "form-check-input",
1356
+ type: "checkbox",
1357
+ name: "deep_clean",
1358
+ checked: false,
1359
+ }),
1360
+ label(
1361
+ {
1362
+ for: "deep_clean",
1363
+ class: "form-label ms-2",
1364
+ },
1365
+ req.__("clean node_modules")
1366
+ )
1367
+ ),
1315
1368
  div(
1316
1369
  { class: "d-flex justify-content-end" },
1317
1370
  button(
@@ -1331,7 +1384,18 @@ router.get(
1331
1384
  req.__("Install")
1332
1385
  )
1333
1386
  )
1334
- )
1387
+ ) +
1388
+ script(
1389
+ domReady(`
1390
+ document.getElementById('tag_select').addEventListener('change', () => {
1391
+ const version = document.getElementById('tag_select').value;
1392
+ if (version) document.getElementById('version_select').value = version;
1393
+ });
1394
+ document.getElementById('version_select').addEventListener('change', () => {
1395
+ document.getElementById('tag_select').value = '';
1396
+ });
1397
+ `)
1398
+ )
1335
1399
  );
1336
1400
  } catch (error) {
1337
1401
  getState().log(
@@ -1343,7 +1407,17 @@ router.get(
1343
1407
  })
1344
1408
  );
1345
1409
 
1346
- const doInstall = async (req, res, version, runPull) => {
1410
+ const cleanNodeModules = async () => {
1411
+ const topSaltcornDir = path.join(__dirname, "..", "..", "..", "..", "..");
1412
+ if (path.basename(topSaltcornDir) === "@saltcorn")
1413
+ await fs.promises.rm(topSaltcornDir, { recursive: true, force: true });
1414
+ else
1415
+ throw new Error(
1416
+ `'${topSaltcornDir}' is not a Saltcorn installation directory`
1417
+ );
1418
+ };
1419
+
1420
+ const doInstall = async (req, res, version, deepClean, runPull) => {
1347
1421
  if (db.getTenantSchema() !== db.connectObj.default_schema) {
1348
1422
  req.flash("error", req.__("Not possible for tenant"));
1349
1423
  res.redirect("/admin");
@@ -1353,6 +1427,14 @@ const doInstall = async (req, res, version, runPull) => {
1353
1427
  ? req.__("Starting upgrade, please wait...\n")
1354
1428
  : req.__("Installing %s, please wait...\n", version)
1355
1429
  );
1430
+ if (deepClean) {
1431
+ res.write(req.__("Cleaning node_modules...\n"));
1432
+ try {
1433
+ await cleanNodeModules();
1434
+ } catch (e) {
1435
+ res.write(req.__("Error cleaning node_modules: %s\n", e.message));
1436
+ }
1437
+ }
1356
1438
  const child = spawn(
1357
1439
  "npm",
1358
1440
  ["install", "-g", `@saltcorn/cli@${version}`, "--unsafe"],
@@ -1390,8 +1472,8 @@ const doInstall = async (req, res, version, runPull) => {
1390
1472
  };
1391
1473
 
1392
1474
  router.post("/install", isAdmin, async (req, res) => {
1393
- const { version } = req.body;
1394
- await doInstall(req, res, version, false);
1475
+ const { version, deep_clean } = req.body;
1476
+ await doInstall(req, res, version, deep_clean === "on", false);
1395
1477
  });
1396
1478
 
1397
1479
  /**
@@ -1404,7 +1486,7 @@ router.post(
1404
1486
  "/upgrade",
1405
1487
  isAdmin,
1406
1488
  error_catcher(async (req, res) => {
1407
- await doInstall(req, res, "latest", true);
1489
+ await doInstall(req, res, "latest", false, true);
1408
1490
  })
1409
1491
  );
1410
1492
  /**
@@ -1742,8 +1824,8 @@ router.get(
1742
1824
  });
1743
1825
  })
1744
1826
  );
1745
- const buildDialogScript = (cordovaBuilderAvailable) => {
1746
- return `<script>
1827
+ const buildDialogScript = (cordovaBuilderAvailable) =>
1828
+ `<script>
1747
1829
  var cordovaBuilderAvailable = ${cordovaBuilderAvailable};
1748
1830
  function showEntrySelect(type) {
1749
1831
  for( const currentType of ["view", "page", "pagegroup"]) {
@@ -1769,7 +1851,6 @@ const buildDialogScript = (cordovaBuilderAvailable) => {
1769
1851
  notifyAlert("Building the app, please wait.", true)
1770
1852
  }
1771
1853
  </script>`;
1772
- };
1773
1854
 
1774
1855
  const imageAvailable = async () => {
1775
1856
  try {
@@ -1827,6 +1908,9 @@ router.get(
1827
1908
  const plugins = (await Plugin.find()).filter(
1828
1909
  (plugin) => ["base", "sbadmin2"].indexOf(plugin.name) < 0
1829
1910
  );
1911
+ const pluginsReadyForMobile = plugins
1912
+ .filter((plugin) => plugin.ready_for_mobile())
1913
+ .map((plugin) => plugin.name);
1830
1914
  const builderSettings =
1831
1915
  getState().getConfig("mobile_builder_settings") || {};
1832
1916
  const dockerAvailable = await imageAvailable();
@@ -1841,6 +1925,11 @@ router.get(
1841
1925
  {
1842
1926
  headerTag: buildDialogScript(dockerAvailable),
1843
1927
  },
1928
+ {
1929
+ headerTag: `<script>var pluginsReadyForMobile = ${JSON.stringify(
1930
+ pluginsReadyForMobile
1931
+ )}</script>`,
1932
+ },
1844
1933
  ],
1845
1934
  contents: {
1846
1935
  above: [
@@ -2877,17 +2966,66 @@ router.get(
2877
2966
  })
2878
2967
  );
2879
2968
 
2969
+ const validateBuildDirName = (buildDirName) => {
2970
+ // ensure characters
2971
+ if (!/^[a-zA-Z0-9_-]+$/.test(buildDirName)) {
2972
+ getState().log(
2973
+ 4,
2974
+ `Invalid characters in build directory name '${buildDirName}'`
2975
+ );
2976
+ return false;
2977
+ }
2978
+ // ensure format is 'build_1234567890'
2979
+ if (!/^build_\d+$/.test(buildDirName)) {
2980
+ getState().log(4, `Invalid build directory name format '${buildDirName}'`);
2981
+ return false;
2982
+ }
2983
+ return true;
2984
+ };
2985
+
2986
+ const validateBuildDir = (buildDir, rootPath) => {
2987
+ const resolvedBuildDir = path.resolve(buildDir);
2988
+ if (!resolvedBuildDir.startsWith(path.join(rootPath, "mobile_app"))) {
2989
+ getState().log(4, `Invalid build directory path '${buildDir}'`);
2990
+ return false;
2991
+ }
2992
+ return true;
2993
+ };
2994
+
2880
2995
  router.get(
2881
2996
  "/build-mobile-app/result",
2882
2997
  isAdmin,
2883
2998
  error_catcher(async (req, res) => {
2884
2999
  const { build_dir_name } = req.query;
3000
+ if (!validateBuildDirName(build_dir_name)) {
3001
+ return res.sendWrap(req.__(`Admin`), {
3002
+ above: [
3003
+ {
3004
+ type: "card",
3005
+ title: req.__("Build Result"),
3006
+ contents: div(req.__("Invalid build directory name")),
3007
+ },
3008
+ ],
3009
+ });
3010
+ }
2885
3011
  const rootFolder = await File.rootFolder();
2886
3012
  const buildDir = path.join(
2887
3013
  rootFolder.location,
2888
3014
  "mobile_app",
2889
3015
  build_dir_name
2890
3016
  );
3017
+ if (!validateBuildDir(buildDir, rootFolder.location)) {
3018
+ return res.sendWrap(req.__(`Admin`), {
3019
+ above: [
3020
+ {
3021
+ type: "card",
3022
+ title: req.__("Build Result"),
3023
+ contents: div(req.__("Invalid build directory path")),
3024
+ },
3025
+ ],
3026
+ });
3027
+ }
3028
+
2891
3029
  const files = await Promise.all(
2892
3030
  fs
2893
3031
  .readdirSync(buildDir)
@@ -234,7 +234,14 @@ router.post(
234
234
  isAdmin,
235
235
  error_catcher(async (req, res) => {
236
236
  const { lang, defstring } = req.params;
237
-
237
+ if (
238
+ lang === "__proto__" ||
239
+ defstring === "__proto__" ||
240
+ lang === "constructor"
241
+ ) {
242
+ res.redirect(`/`);
243
+ return;
244
+ }
238
245
  const cfgStrings = getState().getConfigCopy("localizer_strings");
239
246
  if (cfgStrings[lang]) cfgStrings[lang][defstring] = text(req.body.value);
240
247
  else cfgStrings[lang] = { [defstring]: text(req.body.value) };
package/routes/menu.js CHANGED
@@ -269,6 +269,9 @@ const menuForm = async (req) => {
269
269
  name: "icon",
270
270
  class: "item-menu",
271
271
  input_type: "hidden",
272
+ showIf: {
273
+ type: ["View", "Page", "Page Group", "Link", "Header", "Action"],
274
+ },
272
275
  },
273
276
  {
274
277
  name: "tooltip",
@@ -183,6 +183,12 @@ const pageBuilderData = async (req, context) => {
183
183
  });
184
184
  }
185
185
  }
186
+ const actionsNotRequiringRow = Trigger.action_options({
187
+ notRequireRow: true,
188
+ apiNeverTriggers: true,
189
+ builtInLabel: "Page Actions",
190
+ builtIns: ["GoBack"],
191
+ });
186
192
  const library = (await Library.find({})).filter((l) => l.suitableFor("page"));
187
193
  const fixed_state_fields = {};
188
194
  for (const view of views) {
@@ -228,7 +234,7 @@ const pageBuilderData = async (req, context) => {
228
234
  images,
229
235
  pages,
230
236
  page_groups,
231
- actions,
237
+ actions: actionsNotRequiringRow,
232
238
  builtInActions: ["GoBack"],
233
239
  library,
234
240
  min_role: context.min_role,
package/routes/plugins.js CHANGED
@@ -49,6 +49,8 @@ const {
49
49
  input,
50
50
  label,
51
51
  text,
52
+ script,
53
+ domReady,
52
54
  } = require("@saltcorn/markup/tags");
53
55
  const { search_bar } = require("@saltcorn/markup/helpers");
54
56
  const fs = require("fs");
@@ -614,13 +616,14 @@ router.get(
614
616
  res.set("Page-Title", req.__("%s versions", text(withoutOrg)));
615
617
  const versions = Object.keys(pkgInfo.versions);
616
618
  if (versions.length === 0) throw new Error(req.__("No versions found"));
619
+ const tags = pkgInfo["dist-tags"] || {};
617
620
  let selected = null;
618
621
  if (getState().plugins[plugin.name]) {
619
622
  const mod = await load_plugins.requirePlugin(plugin);
620
623
  if (mod) selected = mod.version;
621
624
  }
622
625
  if (!selected) selected = versions[versions.length - 1];
623
- const packageJson = require("../package.json");
626
+ const scVersion = getState().scVersion;
624
627
  return res.send(
625
628
  form(
626
629
  {
@@ -630,6 +633,7 @@ router.get(
630
633
  input({ type: "hidden", name: "_csrf", value: req.csrfToken() }),
631
634
  div(
632
635
  { class: "form-group" },
636
+ // version
633
637
  label(
634
638
  {
635
639
  for: "version_select",
@@ -645,7 +649,7 @@ router.get(
645
649
  },
646
650
  versions
647
651
  .filter((v) =>
648
- isVersionSupported(v, pkgInfo.versions, packageJson.version)
652
+ isVersionSupported(v, pkgInfo.versions, scVersion)
649
653
  )
650
654
  .map((version) =>
651
655
  option({
@@ -655,6 +659,37 @@ router.get(
655
659
  selected: version === selected,
656
660
  })
657
661
  )
662
+ ),
663
+ // tag
664
+ label(
665
+ {
666
+ for: "tag_select",
667
+ class: "form-label fw-bold mt-2",
668
+ },
669
+ req.__("Tags")
670
+ ),
671
+ select(
672
+ {
673
+ id: "tag_select",
674
+ class: "form-control form-select",
675
+ },
676
+ option({
677
+ id: "empty_opt",
678
+ value: "",
679
+ label: req.__("Select tag"),
680
+ selected: true,
681
+ }),
682
+ Object.keys(tags)
683
+ .filter((tag) =>
684
+ isVersionSupported(tags[tag], pkgInfo.versions, scVersion)
685
+ )
686
+ .map((tag) =>
687
+ option({
688
+ id: `${tag}_opt`,
689
+ value: tags[tag],
690
+ label: `${tag} (${tags[tag]})`,
691
+ })
692
+ )
658
693
  )
659
694
  ),
660
695
  div(
@@ -676,7 +711,19 @@ router.get(
676
711
  req.__("Install")
677
712
  )
678
713
  )
679
- )
714
+ ) +
715
+ script(
716
+ domReady(`
717
+ document.getElementById('tag_select').onchange = () => {
718
+ const version = document.getElementById('tag_select').value;
719
+ if (version) document.getElementById('version_select').value = version;
720
+ };
721
+ document.getElementById('version_select').onchange = () => {
722
+ const tagSelect = document.getElementById('tag_select');
723
+ tagSelect.value = '';
724
+ };
725
+ `)
726
+ )
680
727
  );
681
728
  } catch (error) {
682
729
  getState().log(
@@ -1184,9 +1231,20 @@ router.get(
1184
1231
  const update_permitted =
1185
1232
  db.getTenantSchema() === db.connectObj.default_schema &&
1186
1233
  plugin_db.source === "npm";
1187
- const latest =
1234
+
1235
+ let latest =
1188
1236
  update_permitted &&
1189
1237
  (await get_latest_npm_version(plugin_db.location, 1000));
1238
+ if (
1239
+ latest &&
1240
+ !isVersionSupported(latest, await load_plugins.getEngineInfos(plugin_db)) // with cache
1241
+ ) {
1242
+ // with force fetch
1243
+ latest = supportedVersion(
1244
+ latest,
1245
+ await load_plugins.getEngineInfos(plugin_db, true)
1246
+ );
1247
+ }
1190
1248
  const can_update = update_permitted && latest && mod.version !== latest;
1191
1249
  const can_select_version = update_permitted && plugin_db.source === "npm";
1192
1250
  let pkgjson;
@@ -1332,8 +1390,8 @@ router.get(
1332
1390
  error_catcher(async (req, res) => {
1333
1391
  const schema = db.getTenantSchema();
1334
1392
  if (schema === db.connectObj.default_schema) {
1335
- await upgrade_all_tenants_plugins((p, f) =>
1336
- load_plugins.loadPlugin(p, f)
1393
+ await upgrade_all_tenants_plugins((p, f, forceFetch) =>
1394
+ load_plugins.loadPlugin(p, f, forceFetch)
1337
1395
  );
1338
1396
  req.flash(
1339
1397
  "success",
@@ -1344,7 +1402,9 @@ router.get(
1344
1402
  } else {
1345
1403
  const installed_plugins = await Plugin.find({});
1346
1404
  for (const plugin of installed_plugins) {
1347
- await plugin.upgrade_version((p, f) => load_plugins.loadPlugin(p, f));
1405
+ await plugin.upgrade_version((p, f, forceFetch) =>
1406
+ load_plugins.loadPlugin(p, f, forceFetch)
1407
+ );
1348
1408
  }
1349
1409
  req.flash("success", req.__(`Modules up-to-date`));
1350
1410
  await restart_tenant(loadAllPlugins);
@@ -1370,16 +1430,11 @@ router.get(
1370
1430
  const { name } = req.params;
1371
1431
 
1372
1432
  const plugin = await Plugin.findOne({ name });
1373
- const pkgInfo = await npmFetch.json(
1374
- `https://registry.npmjs.org/${plugin.location}`
1375
- );
1433
+ const versions = await load_plugins.getEngineInfos(plugin, true);
1434
+
1376
1435
  await plugin.upgrade_version(
1377
1436
  (p, f) => load_plugins.loadPlugin(p, f),
1378
- supportedVersion(
1379
- "latest",
1380
- pkgInfo.versions,
1381
- require("../package.json").version
1382
- )
1437
+ supportedVersion("latest", versions, require("../package.json").version)
1383
1438
  );
1384
1439
  req.flash("success", req.__(`Module up-to-date`));
1385
1440
 
package/routes/tables.js CHANGED
@@ -670,7 +670,10 @@ router.get(
670
670
  * @param {string} lbl
671
671
  * @returns {string}
672
672
  */
673
- const badge = (col, lbl) => `<span class="badge bg-${col}">${lbl}</span>&nbsp;`;
673
+ const badge = (col, lbl, title) =>
674
+ `<span ${
675
+ title ? `title="${title}" ` : ""
676
+ }class="badge bg-${col}">${lbl}</span>&nbsp;`;
674
677
 
675
678
  /**
676
679
  * @param {object} f
@@ -708,8 +711,11 @@ const attribBadges = (f) => {
708
711
  ].includes(k)
709
712
  )
710
713
  return;
711
- if(Array.isArray(v) && !v.length) return;
712
- if (v || v === 0) s += badge("secondary", k);
714
+ if (Array.isArray(v) && !v.length) return;
715
+ const title = ["string", "number", "boolean"].includes(typeof v)
716
+ ? `${v}`
717
+ : null;
718
+ if (v || v === 0) s += badge("secondary", k, title);
713
719
  });
714
720
  }
715
721
  return s;
package/serve.js CHANGED
@@ -27,7 +27,7 @@ const {
27
27
  loadAndSaveNewPlugin,
28
28
  loadPlugin,
29
29
  } = require("./load_plugins");
30
- const { getConfig } = require("@saltcorn/data/models/config");
30
+ const { getConfig, setConfig } = require("@saltcorn/data/models/config");
31
31
  const { migrate } = require("@saltcorn/data/migrate");
32
32
  const socketio = require("socket.io");
33
33
  const { createAdapter, setupPrimary } = require("@socket.io/cluster-adapter");
@@ -77,6 +77,17 @@ const ensureJwtSecret = () => {
77
77
  }
78
78
  };
79
79
 
80
+ /**
81
+ * Ensure the engines cache is up to date with the current sc version
82
+ */
83
+ const ensureEnginesCache = async () => {
84
+ const cacheScVersion = await getConfig("engines_cache_sc_version", "");
85
+ if (!cacheScVersion || cacheScVersion !== getState().scVersion) {
86
+ await setConfig("engines_cache", {});
87
+ await setConfig("engines_cache_sc_version", getState().scVersion);
88
+ }
89
+ };
90
+
80
91
  // helpful https://gist.github.com/jpoehls/2232358
81
92
  /**
82
93
  * @param {object} opts
@@ -222,7 +233,10 @@ module.exports =
222
233
  dev,
223
234
  ...appargs
224
235
  } = {}) => {
225
- ensureJwtSecret();
236
+ if (cluster.isMaster) {
237
+ ensureJwtSecret();
238
+ await ensureEnginesCache();
239
+ }
226
240
  process.on("unhandledRejection", (reason, p) => {
227
241
  console.error(reason, "Unhandled Rejection at Promise");
228
242
  });