@saltcorn/server 0.7.3 → 0.7.4-beta.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/auth/admin.js CHANGED
@@ -693,7 +693,8 @@ router.post(
693
693
  } = form.values;
694
694
  if (id) {
695
695
  try {
696
- await db.update("users", { email, role_id, ...rest }, id);
696
+ const u = await User.findOne({ id });
697
+ await u.update({ email, role_id, ...rest });
697
698
  req.flash("success", req.__(`User %s saved`, email));
698
699
  } catch (e) {
699
700
  req.flash("error", req.__(`Error editing user: %s`, e.message));
package/auth/routes.js CHANGED
@@ -199,8 +199,7 @@ const getAuthLinks = (current, noMethods) => {
199
199
  return links;
200
200
  };
201
201
 
202
- const loginWithJwt = async (req, res) => {
203
- const { email, password } = req.query;
202
+ const loginWithJwt = async (email, password, res) => {
204
203
  const user = await User.findOne({ email });
205
204
  if (user && user.checkPassword(password)) {
206
205
  const now = new Date();
@@ -208,7 +207,13 @@ const loginWithJwt = async (req, res) => {
208
207
  const token = jwt.sign(
209
208
  {
210
209
  sub: email,
211
- role_id: user.role_id,
210
+ user: {
211
+ id: user.id,
212
+ email: user.email,
213
+ role_id: user.role_id,
214
+ language: user.language ? user.language : "en",
215
+ disabled: user.disabled,
216
+ },
212
217
  iss: "saltcorn@saltcorn",
213
218
  aud: "saltcorn-mobile-app",
214
219
  iat: now.valueOf(),
@@ -217,6 +222,10 @@ const loginWithJwt = async (req, res) => {
217
222
  );
218
223
  if (!user.last_mobile_login) await user.updateLastMobileLogin(now);
219
224
  res.json(token);
225
+ } else {
226
+ res.json({
227
+ alerts: [{ type: "danger", msg: "Incorrect user or password" }],
228
+ });
220
229
  }
221
230
  };
222
231
 
@@ -900,8 +909,8 @@ router.post(
900
909
  } else {
901
910
  const u = await User.create({ email, password });
902
911
  await send_verification_email(u, req);
903
-
904
- signup_login_with_user(u, req, res);
912
+ if (req.smr) await loginWithJwt(email, password, res);
913
+ else signup_login_with_user(u, req, res);
905
914
  }
906
915
  }
907
916
  })
@@ -1008,7 +1017,8 @@ router.get(
1008
1017
  error_catcher(async (req, res, next) => {
1009
1018
  const { method } = req.params;
1010
1019
  if (method === "jwt") {
1011
- await loginWithJwt(req, res);
1020
+ const { email, password } = req.query;
1021
+ await loginWithJwt(email, password, res);
1012
1022
  } else {
1013
1023
  const auth = getState().auth_methods[method];
1014
1024
  if (auth) {
package/errors.js CHANGED
@@ -7,55 +7,58 @@ const { pre, p, text, h3 } = require("@saltcorn/markup/tags");
7
7
  const Crash = require("@saltcorn/data/models/crash");
8
8
  const { getState } = require("@saltcorn/data/db/state");
9
9
 
10
- module.exports =
11
- /**
12
- *
13
- * @param {object} err
14
- * @param {object} req
15
- * @param {object} res
16
- * @param {*} next
17
- * @returns {Promise<void>}
18
- */
19
- async function (err, req, res, next) {
20
- if (!req.__) req.__ = (s) => s;
10
+ module.exports =
11
+ /**
12
+ *
13
+ * @param {object} err
14
+ * @param {object} req
15
+ * @param {object} res
16
+ * @param {*} next
17
+ * @returns {Promise<void>}
18
+ */
19
+ async function (err, req, res, next) {
20
+ if (!req.__) req.__ = (s) => s;
21
21
 
22
- const devmode = getState().getConfig("development_mode", false);
23
- const log_sql = getState().getConfig("log_sql", false);
24
- const role = (req.user || {}).role_id || 10;
25
- if (err.message && err.message.includes("invalid csrf token")) {
26
- console.error(err.message);
22
+ const devmode = getState().getConfig("development_mode", false);
23
+ const log_sql = getState().getConfig("log_sql", false);
24
+ const role = (req.user || {}).role_id || 10;
25
+ if (err.message && err.message.includes("invalid csrf token")) {
26
+ console.error(err.message);
27
27
 
28
- req.flash("error", req.__("Invalid form data, try again"));
29
- if (req.url && req.url.includes("/auth/login")) res.redirect("/auth/login");
30
- else res.redirect("/");
31
- return;
32
- }
33
- const code = err.httpCode || 500;
34
- const headline = err.headline || "An error occurred";
35
- const severity = err.severity || 2;
36
- const createCrash = severity <= 3;
37
- console.error(err.stack);
38
- if (!(devmode && log_sql) && createCrash) await Crash.create(err, req);
28
+ req.flash("error", req.__("Invalid form data, try again"));
29
+ if (req.url && req.url.includes("/auth/login"))
30
+ res.redirect("/auth/login");
31
+ else res.redirect("/");
32
+ return;
33
+ }
34
+ const code = err.httpCode || 500;
35
+ const headline = err.headline || "An error occurred";
36
+ const severity = err.severity || 2;
37
+ const createCrash = severity <= 3;
38
+ //console.error(err.stack);
39
+ if (!(devmode && log_sql) && createCrash) await Crash.create(err, req);
39
40
 
40
- if (req.xhr) {
41
- res
42
- .status(code)
43
- .send(
44
- devmode || role === 1 ? text(err.message) : req.__("An error occurred")
45
- );
46
- } else
47
- res
48
- .status(code)
49
- .sendWrap(
50
- req.__(headline),
51
- devmode ? pre(text(err.stack)) : h3(req.__(headline)),
52
- role === 1 && !devmode ? pre(text(err.message)) : "",
53
- createCrash
54
- ? p(
55
- req.__(
56
- `A report has been logged and a team of bug-squashing squirrels has been dispatched to deal with the situation.`
41
+ if (req.xhr) {
42
+ res
43
+ .status(code)
44
+ .send(
45
+ devmode || role === 1
46
+ ? text(err.message)
47
+ : req.__("An error occurred")
48
+ );
49
+ } else
50
+ res
51
+ .status(code)
52
+ .sendWrap(
53
+ req.__(headline),
54
+ devmode ? pre(text(err.stack)) : h3(req.__(headline)),
55
+ role === 1 && !devmode ? pre(text(err.message)) : "",
56
+ createCrash
57
+ ? p(
58
+ req.__(
59
+ `A report has been logged and a team of bug-squashing squirrels has been dispatched to deal with the situation.`
60
+ )
57
61
  )
58
- )
59
- : ""
60
- );
61
- };
62
+ : ""
63
+ );
64
+ };
package/locales/en.json CHANGED
@@ -921,5 +921,14 @@
921
921
  "Restoring automated backup": "Restoring automated backup",
922
922
  "No errors detected during configuration check": "No errors detected during configuration check",
923
923
  "%s view - %s on %s": "%s view - %s on %s",
924
- "Back": "Back"
924
+ "Please select at least one platform (android or iOS).": "Please select at least one platform (android or iOS).",
925
+ "Back": "Back",
926
+ "Periodic snapshots enabled": "Periodic snapshots enabled",
927
+ "Snapshot will be made every hour if there are changes": "Snapshot will be made every hour if there are changes",
928
+ "Snapshots": "Snapshots",
929
+ "Snapshot settings updated": "Snapshot settings updated",
930
+ "Download snapshots": "Download snapshots",
931
+ "Snapshot successful": "Snapshot successful",
932
+ "System logging verbosity": "System logging verbosity",
933
+ "Destination URL Formula": "Destination URL Formula"
925
934
  }
package/locales/zh.json CHANGED
@@ -179,7 +179,7 @@
179
179
  "Pack %s installed": "已安装包 %s",
180
180
  "Pack %s uninstalled": "包 %s 已卸载",
181
181
  "Uninstall": "卸载",
182
- "Result preview for ": "结果预览", // todo
182
+ "Result preview for ": "结果预览",
183
183
  "Choose views for <a href=\"/search\">search results</a> for each table.<br/>Set to blank to omit table from global search.": "为每个表选择<a href=\"/search\">搜索结果</a>的视图。<br/>为空则可从全局搜索中忽略表。",
184
184
  "These tables lack suitable views: ": "这些表缺少合适的视图:",
185
185
  "Search configuration": "搜索配置",
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@saltcorn/server",
3
- "version": "0.7.3",
3
+ "version": "0.7.4-beta.0",
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
- "@saltcorn/base-plugin": "0.7.3",
10
- "@saltcorn/builder": "0.7.3",
11
- "@saltcorn/data": "0.7.3",
12
- "@saltcorn/admin-models": "0.7.3",
13
- "@saltcorn/markup": "0.7.3",
14
- "@saltcorn/sbadmin2": "0.7.3",
9
+ "@saltcorn/base-plugin": "0.7.4-beta.0",
10
+ "@saltcorn/builder": "0.7.4-beta.0",
11
+ "@saltcorn/data": "0.7.4-beta.0",
12
+ "@saltcorn/admin-models": "0.7.4-beta.0",
13
+ "@saltcorn/markup": "0.7.4-beta.0",
14
+ "@saltcorn/sbadmin2": "0.7.4-beta.0",
15
15
  "@socket.io/cluster-adapter": "^0.1.0",
16
16
  "@socket.io/sticky": "^1.0.1",
17
17
  "aws-sdk": "^2.1037.0",
@@ -153,7 +153,7 @@ function MenuEditor(e, t) {
153
153
  }
154
154
  });
155
155
  })((n = $(this).closest("li")));
156
- l.onUpdate();
156
+ //l.onUpdate();
157
157
  }),
158
158
  s.on("click", ".btnUp", function (e) {
159
159
  e.preventDefault();
@@ -39,16 +39,26 @@ function apply_showif() {
39
39
  $("[data-show-if]").each(function (ix, element) {
40
40
  var e = $(element);
41
41
  try {
42
- var to_show = new Function(
43
- "e",
44
- "return " + decodeURIComponent(e.attr("data-show-if"))
45
- );
42
+ let to_show = e.data("data-show-if-fun");
43
+ if (!to_show) {
44
+ to_show = new Function(
45
+ "e",
46
+ "return " + decodeURIComponent(e.attr("data-show-if"))
47
+ );
48
+ e.data("data-show-if-fun", to_show);
49
+ }
50
+ if (!e.data("data-closest-form-ns"))
51
+ e.data("data-closest-form-ns", e.closest(".form-namespace"));
46
52
  if (to_show(e))
47
53
  e.show()
48
54
  .find("input, textarea, button, select")
49
55
  .prop("disabled", e.attr("data-disabled") || false);
50
56
  else
51
- e.hide().find("input, textarea, button, select").prop("disabled", true);
57
+ e.hide()
58
+ .find(
59
+ "input:enabled, textarea:enabled, button:enabled, select:enabled"
60
+ )
61
+ .prop("disabled", true);
52
62
  } catch (e) {
53
63
  console.error(e);
54
64
  }
@@ -111,7 +121,12 @@ function apply_showif() {
111
121
  `<option ${
112
122
  `${current}` === `${r[dynwhere.refname]}` ? "selected" : ""
113
123
  } value="${r[dynwhere.refname]}">${
114
- r[dynwhere.summary_field]
124
+ dynwhere.label_formula
125
+ ? new Function(
126
+ `{${Object.keys(r).join(",")}}`,
127
+ "return " + dynwhere.label_formula
128
+ )(r)
129
+ : r[dynwhere.summary_field]
115
130
  }</option>`
116
131
  )
117
132
  );
@@ -510,7 +525,7 @@ const repeaterCopyValuesToForm = (form, editor, noTriggerChange) => {
510
525
  if ($e.length) $e.val(v);
511
526
  else
512
527
  form.append(
513
- `<input type="hidden" name="${k}_${ix}" value="${v}"></input>`
528
+ `<input type="hidden" data-repeater-ix="${ix}" name="${k}_${ix}" value="${v}"></input>`
514
529
  );
515
530
  };
516
531
  vs.forEach((v, ix) => {
@@ -521,8 +536,8 @@ const repeaterCopyValuesToForm = (form, editor, noTriggerChange) => {
521
536
  });
522
537
  });
523
538
  //delete
524
- //for (let ix = vs.length; ix < vs.length + 20; ix++) {
525
- // $(`input[name="${k}_${ix}"]`).remove();
539
+ //for (let ix = vs.length; ix < vs.length + 5; ix++) {
540
+ // $(`input[data-repeater-ix="${ix}"]`).remove();
526
541
  //}
527
542
  $(`input[type=hidden]`).each(function () {
528
543
  const name = $(this).attr("name");
package/routes/admin.js CHANGED
@@ -18,7 +18,13 @@ const { spawn } = require("child_process");
18
18
  const User = require("@saltcorn/data/models/user");
19
19
  const path = require("path");
20
20
  const { getAllTenants } = require("@saltcorn/admin-models/models/tenant");
21
- const { post_btn, renderForm, mkTable, link } = require("@saltcorn/markup");
21
+ const {
22
+ post_btn,
23
+ renderForm,
24
+ mkTable,
25
+ link,
26
+ localeDateTime,
27
+ } = require("@saltcorn/markup");
22
28
  const {
23
29
  div,
24
30
  a,
@@ -42,7 +48,6 @@ const {
42
48
  select,
43
49
  option,
44
50
  fieldset,
45
- legend,
46
51
  ul,
47
52
  li,
48
53
  ol,
@@ -63,6 +68,7 @@ const {
63
68
  restore,
64
69
  auto_backup_now,
65
70
  } = require("@saltcorn/admin-models/models/backup");
71
+ const Snapshot = require("@saltcorn/admin-models/models/snapshot");
66
72
  const {
67
73
  runConfigurationCheck,
68
74
  } = require("@saltcorn/admin-models/models/config-check");
@@ -89,6 +95,7 @@ const moment = require("moment");
89
95
  const View = require("@saltcorn/data/models/view");
90
96
  const { getConfigFile } = require("@saltcorn/data/db/connect");
91
97
  const os = require("os");
98
+ const Page = require("@saltcorn/data/models/page");
92
99
 
93
100
  /**
94
101
  * @type {object}
@@ -118,6 +125,7 @@ const site_id_form = (req) =>
118
125
  "page_custom_html",
119
126
  "development_mode",
120
127
  "log_sql",
128
+ "log_level",
121
129
  "plugins_store_endpoint",
122
130
  "packs_store_endpoint",
123
131
  ...(getConfigFile() ? ["multitenancy_enabled"] : []),
@@ -337,6 +345,9 @@ router.get(
337
345
  backupForm.values.auto_backup_expire_days = getState().getConfig(
338
346
  "auto_backup_expire_days"
339
347
  );
348
+ const aSnapshotForm = snapshotForm(req);
349
+ aSnapshotForm.values.snapshots_enabled =
350
+ getState().getConfig("snapshots_enabled");
340
351
  const isRoot = db.getTenantSchema() === db.connectObj.default_schema;
341
352
 
342
353
  send_admin_page({
@@ -389,6 +400,22 @@ router.get(
389
400
  ),
390
401
  }
391
402
  : { type: "blank", contents: "" },
403
+ {
404
+ type: "card",
405
+ title: req.__("Snapshots"),
406
+ contents: div(
407
+ p(
408
+ i(
409
+ "Snapshots store your application structure and definition, without the table data. Individual views and pages can be restored from snapshots from the <a href='/viewedit'>view</a> or <a href='/pageedit'>pages</a> overviews (\"Restore\" from individual page or view dropdowns)."
410
+ )
411
+ ),
412
+ renderForm(aSnapshotForm, req.csrfToken()),
413
+ a(
414
+ { href: "/admin/snapshot-list" },
415
+ "List/download snapshots &raquo;"
416
+ )
417
+ ),
418
+ },
392
419
  ],
393
420
  },
394
421
  });
@@ -464,6 +491,103 @@ router.get(
464
491
  })
465
492
  );
466
493
 
494
+ router.get(
495
+ "/snapshot-list",
496
+ isAdmin,
497
+ error_catcher(async (req, res) => {
498
+ const snaps = await Snapshot.find();
499
+ send_admin_page({
500
+ res,
501
+ req,
502
+ active_sub: "Backup",
503
+ contents: {
504
+ above: [
505
+ {
506
+ type: "card",
507
+ title: req.__("Download snapshots"),
508
+ contents: div(
509
+ ul(
510
+ snaps.map((snap) =>
511
+ li(
512
+ a(
513
+ {
514
+ href: `/admin/snapshot-download/${encodeURIComponent(
515
+ snap.id
516
+ )}`,
517
+ target: "_blank",
518
+ },
519
+ `${localeDateTime(snap.created)} (${moment(
520
+ snap.created
521
+ ).fromNow()})`
522
+ )
523
+ )
524
+ )
525
+ )
526
+ ),
527
+ },
528
+ ],
529
+ },
530
+ });
531
+ })
532
+ );
533
+
534
+ router.get(
535
+ "/snapshot-download/:id",
536
+ isAdmin,
537
+ error_catcher(async (req, res) => {
538
+ const { id } = req.params;
539
+ const snap = await Snapshot.findOne({ id });
540
+ res.send(snap.pack);
541
+ })
542
+ );
543
+
544
+ router.get(
545
+ "/snapshot-restore/:type/:name",
546
+ isAdmin,
547
+ error_catcher(async (req, res) => {
548
+ const { type, name } = req.params;
549
+ const snaps = await Snapshot.entity_history(type, name);
550
+ res.send(
551
+ mkTable(
552
+ [
553
+ {
554
+ label: "When",
555
+ key: (r) =>
556
+ `${localeDateTime(r.created)} (${moment(r.created).fromNow()})`,
557
+ },
558
+
559
+ {
560
+ label: req.__("Restore"),
561
+ key: (r) =>
562
+ post_btn(
563
+ `/admin/snapshot-restore/${type}/${name}/${r.id}`,
564
+ req.__("Restore"),
565
+ req.csrfToken()
566
+ ),
567
+ },
568
+ ],
569
+ snaps
570
+ )
571
+ );
572
+ })
573
+ );
574
+
575
+ router.post(
576
+ "/snapshot-restore/:type/:name/:id",
577
+ isAdmin,
578
+ error_catcher(async (req, res) => {
579
+ const { type, name, id } = req.params;
580
+ const snap = await Snapshot.findOne({ id });
581
+ await snap.restore_entity(type, name);
582
+ req.flash(
583
+ "success",
584
+ `${type} ${name} restored to snapshot saved ${moment(
585
+ snap.created
586
+ ).fromNow()}`
587
+ );
588
+ res.redirect(`/${type}edit`);
589
+ })
590
+ );
467
591
  router.get(
468
592
  "/auto-backup-download/:filename",
469
593
  isAdmin,
@@ -540,6 +664,43 @@ const autoBackupForm = (req) =>
540
664
  ],
541
665
  });
542
666
 
667
+ const snapshotForm = (req) =>
668
+ new Form({
669
+ action: "/admin/set-snapshot",
670
+ onChange: `saveAndContinue(this);`,
671
+ noSubmitButton: true,
672
+ additionalButtons: [
673
+ {
674
+ label: "Snapshot now",
675
+ id: "btnSnapNow",
676
+ class: "btn btn-outline-secondary",
677
+ onclick: "ajax_post('/admin/snapshot-now')",
678
+ },
679
+ ],
680
+ fields: [
681
+ {
682
+ type: "Bool",
683
+ label: req.__("Periodic snapshots enabled"),
684
+ name: "snapshots_enabled",
685
+ sublabel: req.__(
686
+ "Snapshot will be made every hour if there are changes"
687
+ ),
688
+ },
689
+ ],
690
+ });
691
+ router.post(
692
+ "/set-snapshot",
693
+ isAdmin,
694
+ error_catcher(async (req, res) => {
695
+ const form = await snapshotForm(req);
696
+ form.validate(req.body);
697
+
698
+ await save_config_from_form(form);
699
+ req.flash("success", req.__("Snapshot settings updated"));
700
+ if (!req.xhr) res.redirect("/admin/backup");
701
+ else res.json({ success: "ok" });
702
+ })
703
+ );
543
704
  router.post(
544
705
  "/set-auto-backup",
545
706
  isAdmin,
@@ -579,6 +740,22 @@ router.post(
579
740
  })
580
741
  );
581
742
 
743
+ router.post(
744
+ "/snapshot-now",
745
+ isAdmin,
746
+ error_catcher(async (req, res) => {
747
+ try {
748
+ const taken = await Snapshot.take_if_changed();
749
+ if (taken) req.flash("success", req.__("Snapshot successful"));
750
+ else
751
+ req.flash("success", req.__("No changes detected, snapshot skipped"));
752
+ } catch (e) {
753
+ req.flash("error", e.message);
754
+ }
755
+ res.json({ reload_page: true });
756
+ })
757
+ );
758
+
582
759
  /**
583
760
  * @name get/system
584
761
  * @function
@@ -1064,12 +1241,39 @@ router.get(
1064
1241
  })
1065
1242
  );
1066
1243
 
1244
+ const dialogScript = `<script>
1245
+ function swapEntryInputs(activeTab, activeInput, disabledTab, disabledInput) {
1246
+ activeTab.addClass("active");
1247
+ activeInput.removeClass("d-none");
1248
+ activeInput.addClass("d-block");
1249
+ activeInput.attr("name", "entryPoint");
1250
+ disabledTab.removeClass("active");
1251
+ disabledInput.removeClass("d-block");
1252
+ disabledInput.addClass("d-none");
1253
+ disabledInput.removeAttr("name");
1254
+ }
1255
+
1256
+ function showEntrySelect(type) {
1257
+ const viewNavLin = $("#viewNavLinkID");
1258
+ const pageNavLink = $("#pageNavLinkID");
1259
+ const viewInp = $("#viewInputID");
1260
+ const pageInp = $("#pageInputID");
1261
+ if (type === "page") {
1262
+ swapEntryInputs(pageNavLink, pageInp, viewNavLin, viewInp);
1263
+ }
1264
+ else if (type === "view") {
1265
+ swapEntryInputs(viewNavLin, viewInp, pageNavLink, pageInp);
1266
+ }
1267
+ $("#entryPointTypeID").attr("value", type);
1268
+ }
1269
+ </script>`;
1270
+
1067
1271
  router.get(
1068
1272
  "/build-mobile-app",
1069
1273
  isAdmin,
1070
1274
  error_catcher(async (req, res) => {
1071
- const isRoot = db.getTenantSchema() === db.connectObj.default_schema;
1072
1275
  const views = await View.find();
1276
+ const pages = await Page.find();
1073
1277
  const execBuildMsg =
1074
1278
  "This is still under development and might run longer.";
1075
1279
 
@@ -1077,6 +1281,11 @@ router.get(
1077
1281
  res,
1078
1282
  req,
1079
1283
  active_sub: "Mobile app",
1284
+ headers: [
1285
+ {
1286
+ headerTag: dialogScript,
1287
+ },
1288
+ ],
1080
1289
  contents: {
1081
1290
  above: [
1082
1291
  {
@@ -1094,11 +1303,17 @@ router.get(
1094
1303
  name: "_csrf",
1095
1304
  value: req.csrfToken(),
1096
1305
  }),
1306
+ input({
1307
+ type: "hidden",
1308
+ name: "entryPointType",
1309
+ value: "view",
1310
+ id: "entryPointTypeID",
1311
+ }),
1097
1312
  div(
1098
1313
  { class: "container ps-2" },
1099
1314
  div(
1100
1315
  { class: "row pb-2" },
1101
- div({ class: "col-sm-4 fw-bold" }, "Entry view"),
1316
+ div({ class: "col-sm-4 fw-bold" }, "Entry point"),
1102
1317
  div({ class: "col-sm-4 fw-bold" }, "Platform"),
1103
1318
  div(
1104
1319
  {
@@ -1111,17 +1326,54 @@ router.get(
1111
1326
  { class: "row" },
1112
1327
  div(
1113
1328
  { class: "col-sm-4" },
1329
+ // 'view/page' tabs
1330
+ ul(
1331
+ { class: "nav nav-pills" },
1332
+ li(
1333
+ {
1334
+ class: "nav-item",
1335
+ onClick: "showEntrySelect('view')",
1336
+ },
1337
+ div(
1338
+ { class: "nav-link active", id: "viewNavLinkID" },
1339
+ "View"
1340
+ )
1341
+ ),
1342
+ li(
1343
+ {
1344
+ class: "nav-item",
1345
+ onClick: "showEntrySelect('page')",
1346
+ },
1347
+ div(
1348
+ { class: "nav-link", id: "pageNavLinkID" },
1349
+ "Page"
1350
+ )
1351
+ )
1352
+ ),
1353
+ // select entry-view
1114
1354
  select(
1115
1355
  {
1116
1356
  class: "form-control",
1117
- name: "entryView",
1118
- id: "entryViewInput",
1357
+ name: "entryPoint",
1358
+ id: "viewInputID",
1119
1359
  },
1120
1360
  views
1121
1361
  .map((view) =>
1122
1362
  option({ value: view.name }, view.name)
1123
1363
  )
1124
1364
  .join(",")
1365
+ ),
1366
+ // select entry-page
1367
+ select(
1368
+ {
1369
+ class: "form-control d-none",
1370
+ id: "pageInputID",
1371
+ },
1372
+ pages
1373
+ .map((page) =>
1374
+ option({ value: page.name }, page.name)
1375
+ )
1376
+ .join(",")
1125
1377
  )
1126
1378
  ),
1127
1379
  div(
@@ -1203,7 +1455,7 @@ router.get(
1203
1455
  class: "form-control",
1204
1456
  name: "serverURL",
1205
1457
  id: "serverURLInputId",
1206
- placeholder: "http://10.0.2.2:3000",
1458
+ placeholder: getState().getConfig("base_url") || "",
1207
1459
  })
1208
1460
  )
1209
1461
  )
@@ -1232,7 +1484,8 @@ router.post(
1232
1484
  isAdmin,
1233
1485
  error_catcher(async (req, res) => {
1234
1486
  let {
1235
- entryView,
1487
+ entryPoint,
1488
+ entryPointType,
1236
1489
  androidPlatform,
1237
1490
  iOSPlatform,
1238
1491
  useDocker,
@@ -1251,11 +1504,20 @@ router.post(
1251
1504
  return res.redirect("/admin/build-mobile-app");
1252
1505
  }
1253
1506
  if (appFile && !appFile.endsWith(".apk")) appFile = `${appFile}.apk`;
1507
+ if (!serverURL || serverURL.length == 0) {
1508
+ serverURL = getState().getConfig("base_url") || "";
1509
+ }
1510
+ if (!serverURL.startsWith("http")) {
1511
+ req.flash("error", req.__("Please enter a valid server URL."));
1512
+ return res.redirect("/admin/build-mobile-app");
1513
+ }
1254
1514
  const appOut = path.join(__dirname, "..", "mobile-app-out");
1255
1515
  const spawnParams = [
1256
1516
  "build-app",
1257
- "-v",
1258
- entryView,
1517
+ "-e",
1518
+ entryPoint,
1519
+ "-t",
1520
+ entryPointType,
1259
1521
  "-c",
1260
1522
  appOut,
1261
1523
  "-b",
package/routes/api.js CHANGED
@@ -112,6 +112,13 @@ function accessAllowed(req, user, trigger) {
112
112
  return role <= trigger.min_role;
113
113
  }
114
114
 
115
+ const getFlashes = (req) =>
116
+ ["error", "success", "danger", "warning", "information"]
117
+ .map((type) => {
118
+ return { type, msg: req.flash(type) };
119
+ })
120
+ .filter((a) => a.msg && a.msg.length && a.msg.length > 0);
121
+
115
122
  router.post(
116
123
  "/viewQuery/:viewName/:queryName",
117
124
  error_catcher(async (req, res, next) => {
@@ -134,7 +141,7 @@ router.post(
134
141
  if (queries[queryName]) {
135
142
  const { args } = req.body;
136
143
  const resp = await queries[queryName](...args, true);
137
- res.json({ success: resp });
144
+ res.json({ success: resp, alerts: getFlashes(req) });
138
145
  } else {
139
146
  res.status(404).json({ error: req.__("Not found") });
140
147
  }
@@ -235,6 +242,7 @@ router.get(
235
242
  rows = await table.getJoinedRows(joinOpts);
236
243
  } else if (req_query && req_query !== {}) {
237
244
  const tbl_fields = await table.getFields();
245
+ readState(req_query, tbl_fields, req);
238
246
  const qstate = await stateFieldsToWhere({
239
247
  fields: tbl_fields,
240
248
  approximate: !!approximate,
package/routes/page.js CHANGED
@@ -36,6 +36,8 @@ router.get(
36
36
  "/:pagename",
37
37
  error_catcher(async (req, res) => {
38
38
  const { pagename } = req.params;
39
+ const state = getState();
40
+ state.log(3, `Route /page/${pagename} user=${req.user?.id}`);
39
41
 
40
42
  const role = req.user && req.user.id ? req.user.role_id : 10;
41
43
  const db_page = await Page.findOne({ name: pagename });
@@ -56,10 +58,12 @@ router.get(
56
58
  contents,
57
59
  })
58
60
  );
59
- } else
61
+ } else {
62
+ state.log(2, `Page $pagename} not found or not authorized`);
60
63
  res
61
64
  .status(404)
62
65
  .sendWrap(`${pagename} page`, req.__("Page %s not found", pagename));
66
+ }
63
67
  })
64
68
  );
65
69
 
@@ -90,6 +90,13 @@ const page_dropdown = (page, req) =>
90
90
  '<i class="far fa-copy"></i>&nbsp;' + req.__("Duplicate"),
91
91
  req
92
92
  ),
93
+ a(
94
+ {
95
+ class: "dropdown-item",
96
+ href: `javascript:ajax_modal('/admin/snapshot-restore/page/${page.name}')`,
97
+ },
98
+ '<i class="fas fa-undo-alt"></i>&nbsp;' + req.__("Restore")
99
+ ),
93
100
  div({ class: "dropdown-divider" }),
94
101
  post_dropdown_item(
95
102
  `/pageedit/delete/${page.id}`,
@@ -180,7 +187,8 @@ const pageBuilderData = async (req, context) => {
180
187
  const fixed_state_fields = {};
181
188
  for (const view of views) {
182
189
  fixed_state_fields[view.name] = [];
183
- const table = Table.findOne({ id: view.table_id });
190
+ const table = Table.findOne(view.table_id || view.exttable_name);
191
+
184
192
  const fs = await view.get_state_fields();
185
193
  for (const frec of fs) {
186
194
  const f = new Field(frec);
package/routes/utils.js CHANGED
@@ -128,6 +128,7 @@ const setTenant = (req, res, next) => {
128
128
  } else {
129
129
  db.runWithTenant(other_domain, () => {
130
130
  setLanguage(req, res, state);
131
+ state.log(5, `${req.method} ${req.originalUrl}`);
131
132
  next();
132
133
  });
133
134
  }
@@ -140,12 +141,14 @@ const setTenant = (req, res, next) => {
140
141
  } else {
141
142
  db.runWithTenant(ten, () => {
142
143
  setLanguage(req, res, state);
144
+ state.log(5, `${req.method} ${req.originalUrl}`);
143
145
  next();
144
146
  });
145
147
  }
146
148
  }
147
149
  } else {
148
150
  setLanguage(req, res);
151
+ getState().log(5, `${req.method} ${req.originalUrl}`);
149
152
  next();
150
153
  }
151
154
  };
@@ -240,4 +243,5 @@ module.exports = {
240
243
  getGitRevision,
241
244
  getSessionStore,
242
245
  setTenant,
246
+ get_tenant_from_req
243
247
  };
package/routes/view.js CHANGED
@@ -20,6 +20,7 @@ const {
20
20
  } = require("../routes/utils.js");
21
21
  const { add_edit_bar } = require("../markup/admin.js");
22
22
  const { InvalidConfiguration } = require("@saltcorn/data/utils");
23
+ const { getState } = require("@saltcorn/data/db/state");
23
24
 
24
25
  /**
25
26
  * @type {object}
@@ -44,8 +45,11 @@ router.get(
44
45
  const query = { ...req.query };
45
46
  const view = await View.findOne({ name: viewname });
46
47
  const role = req.user && req.user.id ? req.user.role_id : 10;
48
+ const state = getState();
49
+ state.log(3, `Route /view/${viewname} user=${req.user?.id}`);
47
50
  if (!view) {
48
51
  req.flash("danger", req.__(`No such view: %s`, text(viewname)));
52
+ state.log(2, `View ${viewname} not found`);
49
53
  res.redirect("/");
50
54
  return;
51
55
  }
@@ -56,6 +60,7 @@ router.get(
56
60
  !(await view.authorise_get({ query, req, ...view }))
57
61
  ) {
58
62
  req.flash("danger", req.__("Not authorized"));
63
+ state.log(2, `View ${viewname} not authorized`);
59
64
  res.redirect("/");
60
65
  return;
61
66
  }
@@ -123,13 +128,21 @@ router.post(
123
128
  error_catcher(async (req, res) => {
124
129
  const { viewname, route } = req.params;
125
130
  const role = req.user && req.user.id ? req.user.role_id : 10;
131
+ const state = getState();
132
+ state.log(
133
+ 3,
134
+ `Route /view/${viewname} viewroute ${route} user=${req.user?.id}`
135
+ );
126
136
 
127
137
  const view = await View.findOne({ name: viewname });
128
138
  if (!view) {
129
139
  req.flash("danger", req.__(`No such view: %s`, text(viewname)));
140
+ state.log(2, `View ${viewname} not found`);
130
141
  res.redirect("/");
131
142
  } else if (role > view.min_role) {
132
143
  req.flash("danger", req.__("Not authorized"));
144
+ state.log(2, `View ${viewname} viewroute ${route} not authorized`);
145
+
133
146
  res.redirect("/");
134
147
  } else {
135
148
  await view.runRoute(route, req.body, res, { res, req });
@@ -150,10 +163,12 @@ router.post(
150
163
  const { viewname } = req.params;
151
164
  const role = req.user && req.user.id ? req.user.role_id : 10;
152
165
  const query = { ...req.query };
153
-
166
+ const state = getState();
167
+ state.log(3, `Route /view/${viewname} POST user=${req.user?.id}`);
154
168
  const view = await View.findOne({ name: viewname });
155
169
  if (!view) {
156
170
  req.flash("danger", req.__(`No such view: %s`, text(viewname)));
171
+ state.log(2, `View ${viewname} not found`);
157
172
  res.redirect("/");
158
173
  return;
159
174
  }
@@ -164,6 +179,8 @@ router.post(
164
179
  !(await view.authorise_post({ body: req.body, req, ...view }))
165
180
  ) {
166
181
  req.flash("danger", req.__("Not authorized"));
182
+ state.log(2, `View ${viewname} POST not authorized`);
183
+
167
184
  res.redirect("/");
168
185
  } else if (!view.runPost) {
169
186
  throw new InvalidConfiguration(
@@ -98,6 +98,13 @@ const view_dropdown = (view, req) =>
98
98
  '<i class="far fa-copy"></i>&nbsp;' + req.__("Duplicate"),
99
99
  req
100
100
  ),
101
+ a(
102
+ {
103
+ class: "dropdown-item",
104
+ href: `javascript:ajax_modal('/admin/snapshot-restore/view/${view.name}')`,
105
+ },
106
+ '<i class="fas fa-undo-alt"></i>&nbsp;' + req.__("Restore")
107
+ ),
101
108
  div({ class: "dropdown-divider" }),
102
109
  post_dropdown_item(
103
110
  `/viewedit/delete/${view.id}`,
@@ -355,7 +362,7 @@ router.get(
355
362
  }
356
363
  const tables = await Table.find_with_external();
357
364
  const currentTable = tables.find(
358
- (t) => t.id === viewrow.table_id || t.name === viewrow.exttable_name
365
+ (t) => (t.id && t.id === viewrow.table_id) || t.name === viewrow.exttable_name
359
366
  );
360
367
  viewrow.table_name = currentTable && currentTable.name;
361
368
  if (viewrow.slug && currentTable) {
package/serve.js CHANGED
@@ -30,7 +30,7 @@ const { getConfig } = require("@saltcorn/data/models/config");
30
30
  const { migrate } = require("@saltcorn/data/migrate");
31
31
  const socketio = require("socket.io");
32
32
  const { createAdapter, setupPrimary } = require("@socket.io/cluster-adapter");
33
- const { setTenant, getSessionStore } = require("./routes/utils");
33
+ const { setTenant, getSessionStore, get_tenant_from_req } = require("./routes/utils");
34
34
  const passport = require("passport");
35
35
  const { authenticate } = require("passport");
36
36
  const View = require("@saltcorn/data/models/view");
@@ -44,6 +44,11 @@ const {
44
44
  getAllTenants,
45
45
  } = require("@saltcorn/admin-models/models/tenant");
46
46
  const { auto_backup_now } = require("@saltcorn/admin-models/models/backup");
47
+ const Snapshot = require("@saltcorn/admin-models/models/snapshot");
48
+
49
+ const take_snapshot = async () => {
50
+ return await Snapshot.take_if_changed();
51
+ };
47
52
 
48
53
  // helpful https://gist.github.com/jpoehls/2232358
49
54
  /**
@@ -132,32 +137,33 @@ const workerDispatchMsg = ({ tenant, ...msg }) => {
132
137
  */
133
138
  const onMessageFromWorker =
134
139
  (masterState, { port, watchReaper, disableScheduler, pid }) =>
135
- (msg) => {
136
- //console.log("worker msg", typeof msg, msg);
137
- if (msg === "Start" && !masterState.started) {
138
- masterState.started = true;
139
- runScheduler({
140
- port,
141
- watchReaper,
142
- disableScheduler,
143
- eachTenant,
144
- auto_backup_now,
145
- });
146
- require("./systemd")({ port });
147
- return true;
148
- } else if (msg === "RestartServer") {
149
- process.exit(0);
150
- return true;
151
- } else if (msg.tenant || msg.createTenant) {
152
- ///ie from saltcorn
153
- //broadcast
154
- Object.entries(cluster.workers).forEach(([wpid, w]) => {
155
- if (wpid !== pid) w.send(msg);
156
- });
157
- workerDispatchMsg(msg); //also master
158
- return true;
159
- }
160
- };
140
+ (msg) => {
141
+ //console.log("worker msg", typeof msg, msg);
142
+ if (msg === "Start" && !masterState.started) {
143
+ masterState.started = true;
144
+ runScheduler({
145
+ port,
146
+ watchReaper,
147
+ disableScheduler,
148
+ eachTenant,
149
+ auto_backup_now,
150
+ take_snapshot,
151
+ });
152
+ require("./systemd")({ port });
153
+ return true;
154
+ } else if (msg === "RestartServer") {
155
+ process.exit(0);
156
+ return true;
157
+ } else if (msg.tenant || msg.createTenant) {
158
+ ///ie from saltcorn
159
+ //broadcast
160
+ Object.entries(cluster.workers).forEach(([wpid, w]) => {
161
+ if (wpid !== pid) w.send(msg);
162
+ });
163
+ workerDispatchMsg(msg); //also master
164
+ return true;
165
+ }
166
+ };
161
167
 
162
168
  module.exports =
163
169
  /**
@@ -274,6 +280,7 @@ module.exports =
274
280
  disableScheduler,
275
281
  eachTenant,
276
282
  auto_backup_now,
283
+ take_snapshot,
277
284
  });
278
285
  }
279
286
  Trigger.emitEvent("Startup");
@@ -345,24 +352,33 @@ const setupSocket = (...servers) => {
345
352
  io.attach(server);
346
353
  }
347
354
 
348
- io.use(wrap(setTenant));
355
+ //io.use(wrap(setTenant));
349
356
  io.use(wrap(getSessionStore()));
350
357
  io.use(wrap(passport.initialize()));
351
358
  io.use(wrap(passport.authenticate(["jwt", "session"])));
352
359
  if (process.send && !cluster.isMaster) io.adapter(createAdapter());
353
- getState().setRoomEmitter((viewname, room_id, msg) => {
354
- io.to(`${viewname}_${room_id}`).emit("message", msg);
360
+ getState().setRoomEmitter((tenant, viewname, room_id, msg) => {
361
+ io.to(`${tenant}_${viewname}_${room_id}`).emit("message", msg);
355
362
  });
356
363
  io.on("connection", (socket) => {
357
364
  socket.on("join_room", ([viewname, room_id]) => {
358
- const view = View.findOne({ name: viewname });
359
- if (view.viewtemplateObj.authorize_join) {
360
- view.viewtemplateObj
361
- .authorize_join(view.configuration, room_id, socket.request.user)
362
- .then((authorized) => {
363
- if (authorized) socket.join(`${viewname}_${room_id}`);
364
- });
365
- } else socket.join(`${viewname}_${room_id}`);
365
+ const ten = get_tenant_from_req(socket.request) || "public";
366
+ const f = () => {
367
+ try {
368
+ const view = View.findOne({ name: viewname });
369
+ if (view.viewtemplateObj.authorize_join) {
370
+ view.viewtemplateObj
371
+ .authorize_join(view.configuration, room_id, socket.request.user)
372
+ .then((authorized) => {
373
+ if (authorized) socket.join(`${ten}_${viewname}_${room_id}`);
374
+ });
375
+ } else socket.join(`${ten}_${viewname}_${room_id}`);
376
+ } catch (err) {
377
+ getState().log(1, `Socket join_room error: ${err.stack}`);
378
+ }
379
+ }
380
+ if (ten && ten !== "public") db.runWithTenant(ten, f);
381
+ else f();
366
382
  });
367
383
  });
368
384
  };
package/tests/api.test.js CHANGED
@@ -84,6 +84,23 @@ describe("API read", () => {
84
84
  )
85
85
  );
86
86
  });
87
+ it("should handle fkey args ", async () => {
88
+ const loginCookie = await getAdminLoginCookie();
89
+ const app = await getApp({ disableCsrf: true });
90
+ await request(app)
91
+ .get("/api/patients/?favbook=1")
92
+ .set("Cookie", loginCookie)
93
+ .expect(succeedJsonWith((rows) => rows.length == 1));
94
+ });
95
+ it("should handle fkey args with no value", async () => {
96
+ const loginCookie = await getAdminLoginCookie();
97
+ const app = await getApp({ disableCsrf: true });
98
+ await request(app)
99
+ .get("/api/patients/?favbook=")
100
+ .set("Cookie", loginCookie)
101
+ .expect(succeedJsonWith((rows) => rows.length == 0));
102
+ });
103
+
87
104
  it("should get books for public with search and one field", async () => {
88
105
  const app = await getApp({ disableCsrf: true });
89
106
  await request(app)
@@ -34,8 +34,18 @@ test("updateQueryStringParameter", () => {
34
34
  expect(removeQueryStringParameter("/foo?name=Bar&age=45", "age")).toBe(
35
35
  "/foo?name=Bar"
36
36
  );
37
+ expect(
38
+ updateQueryStringParameter("/foo", "publisher.publisher->name", "AK")
39
+ ).toBe("/foo?publisher.publisher->name=AK");
40
+ expect(
41
+ updateQueryStringParameter(
42
+ "/foo?publisher.publisher->name=AB",
43
+ "publisher.publisher->name",
44
+ "AK"
45
+ )
46
+ ).toBe("/foo?publisher.publisher->name=AK");
37
47
  });
38
-
48
+ //publisher.publisher->name
39
49
  test("updateQueryStringParameter hash", () => {
40
50
  expect(updateQueryStringParameter("/foo#baz", "age", 43)).toBe(
41
51
  "/foo?age=43#baz"