@saltcorn/server 0.9.6-beta.18 → 0.9.6-beta.19

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.
@@ -509,3 +509,26 @@ div.builder-config-field {
509
509
  border: 1px solid black;
510
510
  margin-top: 2px;
511
511
  }
512
+
513
+ div.componets-and-library-accordion {
514
+ padding-right: 0 !important;
515
+ }
516
+ .builder-left-shrunk .componets-and-library-accordion {
517
+ min-height: 0 !important;
518
+ padding-right: 0;
519
+ }
520
+ .toolbox-card .builder-layers {
521
+ min-height: 200px;
522
+ overflow-y: auto;
523
+ max-height: max-content !important;
524
+ }
525
+ .toolbox-card:nth-child(2) {
526
+ margin-bottom: 0px;
527
+ }
528
+ #builder-main-canvas {
529
+ height: calc(100dvh - 50px) !important;
530
+ min-height: 600px;
531
+ }
532
+ #builder-main-canvas.h-100 {
533
+ height: 100% !important;
534
+ }
@@ -536,3 +536,53 @@ button.monospace-copy-btn:hover {
536
536
  button.monospace-copy-btn {
537
537
  position: absolute;
538
538
  }
539
+
540
+ #page-inner-content.sbadmin2-theme .table.table-card-rows {
541
+ border-spacing: 0px 8px;
542
+ border-collapse: separate;
543
+ }
544
+ #page-inner-content.sbadmin2-theme .table.table-card-rows tbody {
545
+ transform: translateY(8px);
546
+ }
547
+ #page-inner-content.sbadmin2-theme .table.table-card-rows tr {
548
+ border-radius: 6px;
549
+ }
550
+ #page-inner-content.sbadmin2-theme
551
+ .table-hover.table-card-rows
552
+ > tbody
553
+ > tr:hover
554
+ > * {
555
+ --tblr-table-bg-state: transparent !important;
556
+ --bs-table-accent-bg: transparent !important;
557
+ color: var(--bs-table-hover-color);
558
+ background-color: var(--bs-table-hover-bg);
559
+ }
560
+ #page-inner-content.sbadmin2-theme .table.table-card-rows thead th {
561
+ background-color: #eaecf4;
562
+ border-block: 1px solid #d3d3d3 !important;
563
+ padding: 18px 15px;
564
+ font-weight: 600;
565
+ }
566
+ #page-inner-content.sbadmin2-theme .table.table-card-rows thead th:first-child {
567
+ border-left: 1px solid #d3d3d3 !important;
568
+ border-radius: 6px 0 0 6px;
569
+ }
570
+ #page-inner-content.sbadmin2-theme .table.table-card-rows thead th:last-child {
571
+ border-right: 1px #d3d3d3 !important;
572
+ border-radius: 0 6px 6px 0;
573
+ }
574
+ #page-inner-content.sbadmin2-theme .table.table-card-rows tbody td {
575
+ border-block: 1px solid #e3e6f0 !important;
576
+ position: relative;
577
+ z-index: 11;
578
+ padding: 15px 15px;
579
+ background-color: #fff;
580
+ }
581
+ #page-inner-content.sbadmin2-theme .table.table-card-rows tbody td:first-child {
582
+ border-left: 1px solid #e3e6f0 !important;
583
+ border-radius: 6px 0 0 6px;
584
+ }
585
+ #page-inner-content.sbadmin2-theme .table.table-card-rows tbody td:last-child {
586
+ border-right: 1px #e3e6f0 !important;
587
+ border-radius: 0 6px 6px 0;
588
+ }
@@ -1034,6 +1034,84 @@ function execLink(path) {
1034
1034
  window.location.href = `${location.origin}${path}`;
1035
1035
  }
1036
1036
 
1037
+ let defferedPrompt;
1038
+ window.addEventListener("beforeinstallprompt", (e) => {
1039
+ e.preventDefault();
1040
+ defferedPrompt = e;
1041
+ });
1042
+
1043
+ function isAndroidMobile() {
1044
+ const ua = navigator.userAgent || navigator.vendor || window.opera;
1045
+ return /android/i.test(ua) && /mobile/i.test(ua);
1046
+ }
1047
+
1048
+ function validatePWAManifest(manifest) {
1049
+ const errors = [];
1050
+ if (!manifest) errors.push("The manifest.json file is missing. ");
1051
+ else {
1052
+ if (!manifest.icons || manifest.icons.length === 0)
1053
+ errors.push("At least one icon is required");
1054
+ else if (
1055
+ manifest.icons.length > 0 &&
1056
+ !manifest.icons.some((icon) => {
1057
+ const sizes = icon.sizes.split("x");
1058
+ const x = parseInt(sizes[0]);
1059
+ const y = parseInt(sizes[1]);
1060
+ return x === y && x >= 144;
1061
+ })
1062
+ ) {
1063
+ errors.push(
1064
+ "At least one square icon of size 144x144 or larger is required"
1065
+ );
1066
+ }
1067
+ if (isAndroidMobile() && manifest.display === "browser") {
1068
+ errors.push(
1069
+ "The display property 'browser' may not work on mobile devices"
1070
+ );
1071
+ }
1072
+ }
1073
+ return errors;
1074
+ }
1075
+
1076
+ function supportsBeforeInstallPrompt() {
1077
+ return "onbeforeinstallprompt" in window;
1078
+ }
1079
+
1080
+ function installPWA() {
1081
+ if (defferedPrompt) defferedPrompt.prompt();
1082
+ else if (!supportsBeforeInstallPrompt()) {
1083
+ notifyAlert({
1084
+ type: "danger",
1085
+ text:
1086
+ "It looks like your browser doesn’t support this feature. " +
1087
+ "Please try the standard installation method provided by your browser, or switch to a different browser.",
1088
+ });
1089
+ } else {
1090
+ const manifestUrl = `${window.location.origin}/notifications/manifest.json`;
1091
+ notifyAlert({
1092
+ type: "danger",
1093
+ text:
1094
+ "Unable to install the app. " +
1095
+ "Please check if the app is already installed or " +
1096
+ `inspect your manifest.json <a href="${manifestUrl}?pretty=true" target="_blank">here</a>`,
1097
+ });
1098
+ $.ajax(manifestUrl, {
1099
+ success: (res) => {
1100
+ const errors = validatePWAManifest(res);
1101
+ if (errors.length > 0)
1102
+ notifyAlert({
1103
+ type: "warning",
1104
+ text: `${errors.join(", ")}`,
1105
+ });
1106
+ },
1107
+ error: (res) => {
1108
+ console.log("Error fetching manifest.json");
1109
+ console.log(res);
1110
+ },
1111
+ });
1112
+ }
1113
+ }
1114
+
1037
1115
  (() => {
1038
1116
  const e = document.querySelector("[data-sidebar-toggler]");
1039
1117
  let closed = localStorage.getItem("sidebarClosed") === "true";
package/routes/fields.js CHANGED
@@ -84,6 +84,10 @@ const fieldForm = async (req, fkey_opts, existing_names, id, hasData) => {
84
84
  sublabel: req.__("Name of the field"),
85
85
  type: "String",
86
86
  attributes: { autofocus: true },
87
+ help: {
88
+ topic: "Field label",
89
+ context: {},
90
+ },
87
91
  validator(s) {
88
92
  if (!s || s === "") return req.__("Missing label");
89
93
  if (!id && existing_names.includes(Field.labelToName(s)))
@@ -104,6 +108,10 @@ const fieldForm = async (req, fkey_opts, existing_names, id, hasData) => {
104
108
  "The type determines the kind of data that can be stored in the field"
105
109
  ),
106
110
  input_type: "select",
111
+ help: {
112
+ topic: "Field types",
113
+ context: {},
114
+ },
107
115
  options: isPrimary
108
116
  ? primaryTypes
109
117
  : getState().type_names.concat(fkey_opts || []),
@@ -459,8 +459,10 @@ const welcome_page = async (req) => {
459
459
  viewCard(views, req),
460
460
  pageCard(pages, req),
461
461
  ],
462
+ class: "welcome-page-row1",
462
463
  },
463
464
  {
465
+ class: "welcome-page-row2",
464
466
  besides: [
465
467
  {
466
468
  type: "card",
@@ -184,8 +184,9 @@ router.post(
184
184
  );
185
185
 
186
186
  router.get(
187
- "/manifest.json",
187
+ "/manifest.json:opt_cache_bust?",
188
188
  error_catcher(async (req, res) => {
189
+ const { pretty } = req.query;
189
190
  const state = getState();
190
191
  const manifest = {
191
192
  name: state.getConfig("site_name"),
@@ -216,6 +217,11 @@ router.get(
216
217
  "red"
217
218
  );
218
219
  }
219
- res.json(manifest);
220
+ if (!pretty) res.json(manifest);
221
+ else {
222
+ const prettyJson = JSON.stringify(manifest, null, 2);
223
+ res.setHeader("Content-Type", "application/json");
224
+ res.send(prettyJson);
225
+ }
220
226
  })
221
227
  );
package/routes/tables.js CHANGED
@@ -93,14 +93,45 @@ const tableForm = async (table, req) => {
93
93
  noSubmitButton: true,
94
94
  onChange: "saveAndContinue(this)",
95
95
  fields: [
96
+ {
97
+ label: req.__("Minimum role to read"),
98
+ sublabel: req.__(
99
+ "User must have this role or higher to read rows from the table, unless they are the owner"
100
+ ),
101
+ help: {
102
+ topic: "Table roles",
103
+ context: {},
104
+ },
105
+ name: "min_role_read",
106
+ input_type: "select",
107
+ options: roleOptions,
108
+ attributes: { asideNext: !table.external && !table.provider_name },
109
+ },
96
110
  ...(!table.external && !table.provider_name
97
111
  ? [
112
+ {
113
+ label: req.__("Minimum role to write"),
114
+ name: "min_role_write",
115
+ input_type: "select",
116
+ help: {
117
+ topic: "Table roles",
118
+ context: {},
119
+ },
120
+ sublabel: req.__(
121
+ "User must have this role or higher to edit or create new rows in the table, unless they are the owner"
122
+ ),
123
+ options: roleOptions,
124
+ },
98
125
  {
99
126
  label: req.__("Ownership field"),
100
127
  name: "ownership_field_id",
101
128
  sublabel: req.__(
102
129
  "The user referred to in this field will be the owner of the row"
103
130
  ),
131
+ help: {
132
+ topic: "Ownership field",
133
+ context: {},
134
+ },
104
135
  input_type: "select",
105
136
  options: [
106
137
  { value: "", label: req.__("None") },
@@ -114,6 +145,10 @@ const tableForm = async (table, req) => {
114
145
  validator: expressionValidator,
115
146
  type: "String",
116
147
  class: "validate-expression",
148
+ help: {
149
+ topic: "Ownership formula",
150
+ context: {},
151
+ },
117
152
  sublabel:
118
153
  req.__("User is treated as owner if true. In scope: ") +
119
154
  ["user", ...fields.map((f) => f.name)]
@@ -126,6 +161,10 @@ const tableForm = async (table, req) => {
126
161
  sublabel: req.__(
127
162
  "Add relations to this table in dropdown options for ownership field"
128
163
  ),
164
+ help: {
165
+ topic: "User groups",
166
+ context: {},
167
+ },
129
168
  name: "is_user_group",
130
169
  type: "Bool",
131
170
  },
@@ -141,28 +180,9 @@ const tableForm = async (table, req) => {
141
180
  ),
142
181
  //options: roleOptions,
143
182
  },
144
- {
145
- label: req.__("Minimum role to read"),
146
- sublabel: req.__(
147
- "User must have this role or higher to read rows from the table, unless they are the owner"
148
- ),
149
- name: "min_role_read",
150
- input_type: "select",
151
- options: roleOptions,
152
- attributes: { asideNext: !table.external && !table.provider_name },
153
- },
154
183
  ...(table.external || table.provider_name
155
184
  ? []
156
185
  : [
157
- {
158
- label: req.__("Minimum role to write"),
159
- name: "min_role_write",
160
- input_type: "select",
161
- sublabel: req.__(
162
- "User must have this role or higher to edit or create new rows in the table, unless they are the owner"
163
- ),
164
- options: roleOptions,
165
- },
166
186
  {
167
187
  label: req.__("Version history"),
168
188
  sublabel: req.__("Track table data changes over time"),
package/routes/tenant.js CHANGED
@@ -44,7 +44,7 @@ const {
44
44
  const db = require("@saltcorn/data/db");
45
45
 
46
46
  const { loadAllPlugins, loadAndSaveNewPlugin } = require("../load_plugins");
47
- const { isAdmin, error_catcher } = require("./utils.js");
47
+ const { isAdmin, error_catcher, is_ip_address } = require("./utils.js");
48
48
  const User = require("@saltcorn/data/models/user");
49
49
  const File = require("@saltcorn/data/models/file");
50
50
  const {
@@ -117,18 +117,6 @@ const create_tenant_allowed = (req) => {
117
117
  return user_role <= required_role;
118
118
  };
119
119
 
120
- /**
121
- * Check that String is IPv4 address
122
- * @param {string} hostname
123
- * @returns {boolean|string[]}
124
- */
125
- // TBD not sure that false is correct return if type of is not string
126
- // TBD Add IPv6 support
127
- const is_ip_address = (hostname) => {
128
- if (typeof hostname !== "string") return false;
129
- return hostname.split(".").every((s) => +s >= 0 && +s <= 255);
130
- };
131
-
132
120
  const get_cfg_tenant_base_url = (req) =>
133
121
  remove_leading_chars(
134
122
  ".",
package/routes/utils.js CHANGED
@@ -155,6 +155,8 @@ const get_tenant_from_req = (req, hostPartsOffset) => {
155
155
  if (req.subdomains && req.subdomains.length == 0)
156
156
  return db.connectObj.default_schema;
157
157
  if (!req.subdomains && req.headers.host) {
158
+ if (is_ip_address(req.headers.host.split(":")[0]))
159
+ return db.connectObj.default_schema;
158
160
  const parts = req.headers.host.split(".");
159
161
  if (parts.length < (!hostPartsOffset ? 3 : 3 - hostPartsOffset))
160
162
  return db.connectObj.default_schema;
@@ -297,7 +299,7 @@ const getGitRevision = () => db.connectObj.git_commit;
297
299
  * Gets session store
298
300
  * @returns {session|cookieSession}
299
301
  */
300
- const getSessionStore = () => {
302
+ const getSessionStore = (pruneInterval) => {
301
303
  /*if (getState().getConfig("cookie_sessions", false)) {
302
304
  return cookieSession({
303
305
  keys: [db.connectObj.session_secret || is.str.generate()],
@@ -320,6 +322,7 @@ const getSessionStore = () => {
320
322
  schemaName: db.connectObj.default_schema,
321
323
  pool: db.pool,
322
324
  tableName: "_sc_session",
325
+ pruneSessionInterval: pruneInterval > 0 ? pruneInterval : false,
323
326
  }),
324
327
  secret: db.connectObj.session_secret || is.str.generate(),
325
328
  resave: false,
@@ -348,6 +351,18 @@ const is_relative_url = (url) => {
348
351
  return typeof url === "string" && !url.includes(":/") && !url.includes("//");
349
352
  };
350
353
 
354
+ /**
355
+ * Check that String is IPv4 address
356
+ * @param {string} hostname
357
+ * @returns {boolean|string[]}
358
+ */
359
+ // TBD not sure that false is correct return if type of is not string
360
+ // TBD Add IPv6 support
361
+ const is_ip_address = (hostname) => {
362
+ if (typeof hostname !== "string") return false;
363
+ return hostname.split(".").every((s) => +s >= 0 && +s <= 255);
364
+ };
365
+
351
366
  const admin_config_route = ({
352
367
  router,
353
368
  path,
@@ -556,6 +571,7 @@ module.exports = {
556
571
  get_tenant_from_req,
557
572
  addOnDoneRedirect,
558
573
  is_relative_url,
574
+ is_ip_address,
559
575
  get_sys_info,
560
576
  admin_config_route,
561
577
  sendHtmlFile,
package/serve.js CHANGED
@@ -235,6 +235,10 @@ module.exports =
235
235
  : defaultNCPUs;
236
236
 
237
237
  const letsEncrypt = await getConfig("letsencrypt", false);
238
+ const pruneSessionInterval = +(await getConfig(
239
+ "prune_session_interval",
240
+ 900
241
+ ));
238
242
  const masterState = {
239
243
  started: false,
240
244
  listeningTo: new Set([]),
@@ -287,7 +291,11 @@ module.exports =
287
291
  })
288
292
  .ready((glx) => {
289
293
  const httpsServer = glx.httpsServer();
290
- setupSocket(appargs?.subdomainOffset, httpsServer);
294
+ setupSocket(
295
+ appargs?.subdomainOffset,
296
+ pruneSessionInterval,
297
+ httpsServer
298
+ );
291
299
  httpsServer.setTimeout(timeout * 1000);
292
300
  process.on("message", workerDispatchMsg);
293
301
  glx.serveApp(app);
@@ -344,6 +352,10 @@ const nonGreenlockWorkerSetup = async (appargs, port) => {
344
352
  const cert = getState().getConfig("custom_ssl_certificate", "");
345
353
  const key = getState().getConfig("custom_ssl_private_key", "");
346
354
  const timeout = +getState().getConfig("timeout", 120);
355
+ const pruneSessionInterval = +(await getState().getConfig(
356
+ "prune_session_interval",
357
+ 900
358
+ ));
347
359
  // Server with http on port 80 / https on 443
348
360
  // todo resolve hardcode
349
361
  if (port === 80 && cert && key) {
@@ -354,7 +366,12 @@ const nonGreenlockWorkerSetup = async (appargs, port) => {
354
366
  // todo timeout to config
355
367
  httpServer.setTimeout(timeout * 1000);
356
368
  httpsServer.setTimeout(timeout * 1000);
357
- setupSocket(appargs?.subdomainOffset, httpServer, httpsServer);
369
+ setupSocket(
370
+ appargs?.subdomainOffset,
371
+ pruneSessionInterval,
372
+ httpServer,
373
+ httpsServer
374
+ );
358
375
  httpServer.listen(port, () => {
359
376
  console.log("HTTP Server running on port 80");
360
377
  });
@@ -367,7 +384,7 @@ const nonGreenlockWorkerSetup = async (appargs, port) => {
367
384
  // server with http only
368
385
  const http = require("http");
369
386
  const httpServer = http.createServer(app);
370
- setupSocket(appargs?.subdomainOffset, httpServer);
387
+ setupSocket(appargs?.subdomainOffset, pruneSessionInterval, httpServer);
371
388
 
372
389
  // todo timeout to config
373
390
  // todo refer in doc to httpserver doc
@@ -384,7 +401,7 @@ const nonGreenlockWorkerSetup = async (appargs, port) => {
384
401
  *
385
402
  * @param {...*} servers
386
403
  */
387
- const setupSocket = (subdomainOffset, ...servers) => {
404
+ const setupSocket = (subdomainOffset, pruneSessionInterval, ...servers) => {
388
405
  // https://socket.io/docs/v4/middlewares/
389
406
  const wrap = (middleware) => (socket, next) =>
390
407
  middleware(socket.request, {}, next);
@@ -395,7 +412,7 @@ const setupSocket = (subdomainOffset, ...servers) => {
395
412
  }
396
413
 
397
414
  const passportInit = passport.initialize();
398
- const sessionStore = getSessionStore();
415
+ const sessionStore = getSessionStore(pruneSessionInterval);
399
416
  const setupNamespace = (namespace) => {
400
417
  //io.of(namespace).use(wrap(setTenant));
401
418
  io.of(namespace).use(wrap(sessionStore));
@@ -452,6 +469,11 @@ const setupSocket = (subdomainOffset, ...servers) => {
452
469
  socketIds.push(socket.id);
453
470
  await getState().setConfig("joined_log_socket_ids", [...socketIds]);
454
471
  callback({ status: "ok" });
472
+ setTimeout(() => {
473
+ io.of("/")
474
+ .to(`_logs_${tenant}_`)
475
+ .emit("test_conn_msg", { text: "test message" });
476
+ }, 1000);
455
477
  }
456
478
  } catch (err) {
457
479
  getState().log(1, `Socket join_logs stream: ${err.stack}`);
package/wrapper.js CHANGED
@@ -170,6 +170,7 @@ const get_headers = (req, version_tag, description, extras = []) => {
170
170
  const favicon = state.getConfig("favicon_id", null);
171
171
  const notification_in_menu = state.getConfig("notification_in_menu");
172
172
  const pwa_enabled = state.getConfig("pwa_enabled");
173
+ const is_root = req.user?.role_id === 1;
173
174
 
174
175
  const iconHeader = favicon
175
176
  ? [
@@ -219,7 +220,9 @@ const get_headers = (req, version_tag, description, extras = []) => {
219
220
  from_cfg.push({ scriptBody: domReady(`check_saltcorn_notifications()`) });
220
221
  if (pwa_enabled) {
221
222
  from_cfg.push({
222
- headerTag: `<link rel="manifest" href="/notifications/manifest.json">`,
223
+ headerTag: `<link rel="manifest" href="/notifications/manifest.json${
224
+ is_root ? new Date().valueOf() : ""
225
+ }">`,
223
226
  });
224
227
  from_cfg.push({
225
228
  scriptBody: `if('serviceWorker' in navigator) {