@saltcorn/server 0.9.0 → 0.9.1-beta.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.
package/auth/admin.js CHANGED
@@ -376,6 +376,8 @@ const http_settings_form = async (req) =>
376
376
  //"cookie_sessions",
377
377
  "public_cache_maxage",
378
378
  "custom_http_headers",
379
+ "body_limit",
380
+ "url_encoded_limit",
379
381
  ],
380
382
  action: "/useradmin/http",
381
383
  submitLabel: req.__("Save"),
package/auth/testhelp.js CHANGED
@@ -215,6 +215,18 @@ const succeedJsonWith = (pred) => (res) => {
215
215
  }
216
216
  };
217
217
 
218
+ const succeedJsonWithWholeBody = (pred) => (res) => {
219
+ if (res.statusCode !== 200) {
220
+ console.log(res.text);
221
+ throw new Error(`Expected status 200, received ${res.statusCode}`);
222
+ }
223
+
224
+ if (!pred(res.body)) {
225
+ console.log(res.body);
226
+ throw new Error(`Not satisfied`);
227
+ }
228
+ };
229
+
218
230
  /**
219
231
  *
220
232
  * @param {number} code
@@ -260,6 +272,7 @@ module.exports = {
260
272
  notAuthorized,
261
273
  respondJsonWith,
262
274
  toSucceedWithImage,
275
+ succeedJsonWithWholeBody,
263
276
  resToLoginCookie,
264
277
  itShouldIncludeTextForAdmin,
265
278
  };
@@ -0,0 +1,12 @@
1
+ This trigger can be run with by making an HTTP request to
2
+
3
+ {{scState.getConfig("base_url","").replace(/\/$/, "")}}/api/action/{{ query.name }}
4
+
5
+ If you make a POST request, the POST body is expected to be JSON and its value
6
+ is accessible using the `row` variable (e.g. in a `run_js_code`)
7
+
8
+ If you make a GET request, the query string values
9
+ are accessible as a JSON object using the `row` variable (e.g. in a `run_js_code`).
10
+
11
+ If you return a value from the action, it will be available in the `data`
12
+ subfield of the JSON body in the HTTP response
@@ -0,0 +1,71 @@
1
+ The event type for triggers determines when the chosen action should be run.
2
+ The different event types options come from a variety of the types and sources.
3
+
4
+ These events also form the basis of the event log. Use the log settings to enable or disable
5
+ recording of the occurrence of events.
6
+
7
+ ## Database events
8
+
9
+ These conditions are triggered by changes to rows in tables. Together with the event type
10
+ a specific table is chosen. The individual conditions are:
11
+
12
+ **Insert**: run the action when a new row is inserted in the table. This is a good choice
13
+ when a table itself represents actions to be carried out; for instance a table of
14
+ outbound emails would have a trigger with When = Insert and Action = send_email
15
+
16
+ **Update**: run this action when changes are made to an existing row. The old row can
17
+ be accessed with the `old_row` variable.
18
+
19
+ **Delete**: run this action when a row is deleted
20
+
21
+ ## Periodic events
22
+
23
+ These triggers are run periodically at different times.
24
+
25
+ **Weekly**: run this once a week.
26
+
27
+ **Daily**: run this once a day.
28
+
29
+ **Hourly**: run this once an hour.
30
+
31
+ **Often**: run this every 5 minutes.
32
+
33
+ ## User-based events
34
+
35
+ **PageLoad**: run this whenever a page or view is loaded. If you set up the event log to
36
+ record these events you can use this as a basis for an analytics system.
37
+
38
+ **Login**: run this whenever a user log in successfully
39
+
40
+ **LoginFailed**: run this whenever a user login failed
41
+
42
+ **UserVerified**: run this when a user is verified, if an appropriate module for
43
+ user verification is enabled.
44
+
45
+ ## System-based events
46
+
47
+ **Error**: run this whenever an error occurs
48
+
49
+ **Startup**: run this whenever this saltcorn process initializes. 
50
+
51
+ ## Other events
52
+
53
+ **Never**: this trigger is never run on its own. However triggers that are marked as never
54
+ can be chosen as the target action for a button in the UI. Use this if you have a complex
55
+ configuration for an action that needs to be run in response to a button click, or if you
56
+ have a configuration that needs to be reused between two different buttons in two different
57
+ views. You can also use this to switch off a trigger that is running on a different event
58
+ type without deleting it.
59
+
60
+ **API call**: this trigger can be run in response to an inbound API call. To see the URL
61
+ and further help, click the help icon next to the "API call" label in the trigger list.
62
+
63
+ ## Custom events
64
+
65
+ You can create your own event type which can then be triggered with an emit_event action
66
+ or the `emitEvent` call in a run js code action
67
+
68
+ ## Events supplied by modules
69
+
70
+ Modules can provide new event types. For instance the mqtt module provides an event
71
+ type based on receiving new messages.
package/locales/en.json CHANGED
@@ -1268,5 +1268,14 @@
1268
1268
  "Pack file": "Pack file",
1269
1269
  "Upload a pack file": "Upload a pack file",
1270
1270
  "No menu": "No menu",
1271
- "Omit the menu from this page": "Omit the menu from this page"
1271
+ "Omit the menu from this page": "Omit the menu from this page",
1272
+ "%s finished without a result": "%s finished without a result",
1273
+ "Body size limit (Kb)": "Body size limit (Kb)",
1274
+ "Maximum request body size in kilobytes": "Maximum request body size in kilobytes",
1275
+ "URL encoded size limit (Kb)": "URL encoded size limit (Kb)",
1276
+ "Maximum URL encoded request size in kilobytes": "Maximum URL encoded request size in kilobytes",
1277
+ "HTML file": "HTML file",
1278
+ "HTML file to use as page content": "HTML file to use as page content",
1279
+ "Offline mode: cannot load file": "Offline mode: cannot load file",
1280
+ "None - use drag and drop builder": "None - use drag and drop builder"
1272
1281
  }
package/locales/fr.json CHANGED
@@ -286,5 +286,11 @@
286
286
  "Users and security": "Users and security",
287
287
  "Site structure": "Site structure",
288
288
  "Events": "Events",
289
- "Are you sure?": "Are you sure?"
289
+ "Are you sure?": "Are you sure?",
290
+ "API token": "API token",
291
+ "No API token issued": "No API token issued",
292
+ "Generate": "Generate",
293
+ "Two-factor authentication": "Two-factor authentication",
294
+ "Two-factor authentication is disabled": "Two-factor authentication is disabled",
295
+ "Enable 2FA": "Enable 2FA"
290
296
  }
package/package.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "name": "@saltcorn/server",
3
- "version": "0.9.0",
3
+ "version": "0.9.1-beta.1",
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.9.0",
10
- "@saltcorn/builder": "0.9.0",
11
- "@saltcorn/data": "0.9.0",
12
- "@saltcorn/admin-models": "0.9.0",
13
- "@saltcorn/filemanager": "0.9.0",
14
- "@saltcorn/markup": "0.9.0",
15
- "@saltcorn/sbadmin2": "0.9.0",
9
+ "@saltcorn/base-plugin": "0.9.1-beta.1",
10
+ "@saltcorn/builder": "0.9.1-beta.1",
11
+ "@saltcorn/data": "0.9.1-beta.1",
12
+ "@saltcorn/admin-models": "0.9.1-beta.1",
13
+ "@saltcorn/filemanager": "0.9.1-beta.1",
14
+ "@saltcorn/markup": "0.9.1-beta.1",
15
+ "@saltcorn/sbadmin2": "0.9.1-beta.1",
16
16
  "@socket.io/cluster-adapter": "^0.2.1",
17
17
  "@socket.io/sticky": "^1.0.1",
18
18
  "adm-zip": "0.5.10",
@@ -154,7 +154,8 @@ function apply_showif() {
154
154
  e.empty();
155
155
  e.prop("data-fetch-options-current-set", qs);
156
156
  const toAppend = [];
157
- if (!dynwhere.required) toAppend.push(`<option></option>`);
157
+ if (!dynwhere.required)
158
+ toAppend.push({ label: dynwhere.neutral_label || "" });
158
159
  let currentDataOption = undefined;
159
160
  const dataOptions = [];
160
161
  //console.log(success);
@@ -173,12 +174,28 @@ function apply_showif() {
173
174
  const selected = `${current}` === `${r[dynwhere.refname]}`;
174
175
  dataOptions.push({ text: label, value });
175
176
  if (selected) currentDataOption = value;
176
- const html = `<option ${
177
- selected ? "selected" : ""
178
- } value="${value}">${label}</option>`;
179
- toAppend.push(html);
177
+ toAppend.push({ selected, value, label });
180
178
  });
181
- e.html(toAppend.join(""));
179
+ toAppend.sort((a, b) =>
180
+ a.label === dynwhere.neutral_label
181
+ ? -1
182
+ : b.label === dynwhere.neutral_label
183
+ ? 1
184
+ : (a.label?.toLowerCase?.() || a.label) >
185
+ (b.label?.toLowerCase?.() || b.label)
186
+ ? 1
187
+ : -1
188
+ );
189
+ e.html(
190
+ toAppend
191
+ .map(
192
+ ({ label, value, selected }) =>
193
+ `<option${selected ? ` selected` : ""}${
194
+ value ? ` value="${value}"` : ""
195
+ }>${label || ""}</option>`
196
+ )
197
+ .join("")
198
+ );
182
199
 
183
200
  //TODO: also sort inserted HTML options
184
201
  dataOptions.sort((a, b) =>
@@ -568,8 +585,11 @@ function initialize_page() {
568
585
  $(this).find("span.current time").attr("datetime"); // ||
569
586
  //$(this).children("span.current").html();
570
587
  }
571
- console.log({ type, current });
588
+ if (type === "Bool") {
589
+ current = current === "true";
590
+ }
572
591
  var is_key = type?.startsWith("Key:");
592
+ const resetHtml = this.outerHTML;
573
593
  const opts = encodeURIComponent(
574
594
  JSON.stringify({
575
595
  url,
@@ -580,6 +600,7 @@ function initialize_page() {
580
600
  type,
581
601
  is_key,
582
602
  schema,
603
+ resetHtml,
583
604
  ...(decimalPlaces ? { decimalPlaces } : {}),
584
605
  })
585
606
  );
@@ -632,7 +653,11 @@ function initialize_page() {
632
653
  : ""
633
654
  }
634
655
  <input type="${
635
- type === "Integer" || type === "Float" ? "number" : "text"
656
+ type === "Integer" || type === "Float"
657
+ ? "number"
658
+ : type === "Bool"
659
+ ? "checkbox"
660
+ : "text"
636
661
  }" ${
637
662
  type === "Float"
638
663
  ? `step="${
@@ -643,7 +668,13 @@ function initialize_page() {
643
668
  : "any"
644
669
  }"`
645
670
  : ""
646
- } name="${key}" value="${escapeHtml(current)}">
671
+ } name="${key}" ${
672
+ type === "Bool"
673
+ ? current
674
+ ? "checked"
675
+ : ""
676
+ : `value="${escapeHtml(current)}"`
677
+ }>
647
678
  <button type="submit" class="btn btn-sm btn-primary">OK</button>
648
679
  <button onclick="cancel_inline_edit(event, '${opts}')" type="button" class="btn btn-sm btn-danger"><i class="fas fa-times"></i></button>
649
680
  </form>`
@@ -760,33 +791,7 @@ function cancel_inline_edit(e, opts1) {
760
791
  const isNode = typeof parent?.saltcorn?.data?.state === "undefined";
761
792
  var opts = JSON.parse(decodeURIComponent(opts1 || "") || "{}");
762
793
  var form = $(e.target).closest("form");
763
- var json_fk_opt;
764
- if (opts.schema) {
765
- json_fk_opt = form.find(`option[value="${opts.current}"]`).text();
766
- }
767
- form.replaceWith(`<div
768
- data-inline-edit-field="${opts.key}"
769
- ${opts.ajax ? `data-inline-edit-ajax="true"` : ""}
770
- ${opts.type ? `data-inline-edit-type="${opts.type}"` : ""}
771
- ${opts.current ? `data-inline-edit-current="${opts.current}"` : ""}
772
- ${
773
- opts.current_label
774
- ? `data-inline-edit-current-label="${opts.current_label}"`
775
- : ""
776
- }
777
- ${
778
- opts.schema
779
- ? `data-inline-edit-schema="${encodeURIComponent(
780
- JSON.stringify(opts.schema)
781
- )}"`
782
- : ""
783
- }
784
- data-inline-edit-dest-url="${opts.url}">
785
- <span class="current">${
786
- json_fk_opt || opts.current_label || opts.current
787
- }</span>
788
- <i class="editicon ${!isNode ? "visible" : ""} fas fa-edit ms-1"></i>
789
- </div>`);
794
+ form.replaceWith(opts.resetHtml);
790
795
  initialize_page();
791
796
  }
792
797
 
@@ -794,7 +799,8 @@ function inline_submit_success(e, form, opts) {
794
799
  const isNode = typeof parent?.saltcorn?.data?.state === "undefined";
795
800
  const formDataArray = form.serializeArray();
796
801
  if (opts) {
797
- let rawVal = formDataArray.find((f) => f.name == opts.key).value;
802
+ let fdEntry = formDataArray.find((f) => f.name == opts.key);
803
+ let rawVal = opts.type === "Bool" ? !!fdEntry : fdEntry.value;
798
804
  let val =
799
805
  opts.is_key || (opts.schema && opts.schema.type.startsWith("Key to "))
800
806
  ? form.find("select").find("option:selected").text()
@@ -829,9 +835,13 @@ function inline_submit_success(e, form, opts) {
829
835
  function inline_ajax_submit(e, opts1) {
830
836
  var opts = JSON.parse(decodeURIComponent(opts1 || "") || "{}");
831
837
  e.preventDefault();
838
+
832
839
  var form = $(e.target).closest("form");
833
840
  var form_data = form.serialize();
834
841
  var url = form.attr("action");
842
+ if (opts.type === "Bool" && !form_data.includes(`${opts.key}=on`)) {
843
+ form_data += `&${opts.key}=off`;
844
+ }
835
845
  $.ajax(url, {
836
846
  type: "POST",
837
847
  headers: {
@@ -1022,7 +1032,14 @@ function common_done(res, viewname, isWeb = true) {
1022
1032
  if (res.notify) handle(res.notify, notifyAlert);
1023
1033
  if (res.error)
1024
1034
  handle(res.error, (text) => notifyAlert({ type: "danger", text: text }));
1025
- if (res.eval_js) handle(res.eval_js, eval);
1035
+
1036
+ if (res.eval_js && res.row && res.field_names) {
1037
+ const f = new Function(`row, {${res.field_names}}`, res.eval_js);
1038
+ const evalres = f(res.row, res.row);
1039
+ if (evalres) common_done(evalres, viewname, isWeb);
1040
+ } else if (res.eval_js) {
1041
+ handle(res.eval_js, eval);
1042
+ }
1026
1043
 
1027
1044
  if (res.reload_page) {
1028
1045
  (isWeb ? location : parent).reload(); //TODO notify to cookie if reload or goto
@@ -397,16 +397,7 @@ function saveAndContinue(e, k) {
397
397
  error: function (request) {
398
398
  var ct = request.getResponseHeader("content-type") || "";
399
399
  if (ct.startsWith && ct.startsWith("application/json")) {
400
- var errorArea = form.parent().find(".full-form-error");
401
- if (errorArea.length) {
402
- errorArea.text(request.responseJSON.error);
403
- } else {
404
- form
405
- .parent()
406
- .append(
407
- `<p class="text-danger full-form-error">${request.responseJSON.error}</p>`
408
- );
409
- }
400
+ notifyAlert({ type: "danger", text: request.responseJSON.error });
410
401
  } else {
411
402
  $("#page-inner-content").html(request.responseText);
412
403
  initialize_page();
@@ -572,6 +563,20 @@ function ajax_post_btn(e, reload_on_done, reload_delay) {
572
563
 
573
564
  return false;
574
565
  }
566
+
567
+ function api_action_call(name, body) {
568
+ $.ajax(`/api/action/${name}`, {
569
+ type: "POST",
570
+ headers: {
571
+ "CSRF-Token": _sc_globalCsrf,
572
+ },
573
+ data: body,
574
+ success: function (res) {
575
+ common_done(res.data);
576
+ },
577
+ });
578
+ }
579
+
575
580
  function make_unique_field(
576
581
  id,
577
582
  table_id,
package/routes/actions.js CHANGED
@@ -171,6 +171,7 @@ const triggerForm = async (req, trigger) => {
171
171
  required: true,
172
172
  options: Trigger.when_options.map((t) => ({ value: t, label: t })),
173
173
  sublabel: req.__("Event type which runs the trigger"),
174
+ help: { topic: "Event types" },
174
175
  attributes: {
175
176
  explainers: {
176
177
  Often: req.__("Every 5 minutes"),
package/routes/api.js CHANGED
@@ -332,7 +332,7 @@ router.get(
332
332
  * @function
333
333
  * @memberof module:routes/api~apiRouter
334
334
  */
335
- router.post(
335
+ router.all(
336
336
  "/action/:actionname/",
337
337
  error_catcher(async (req, res, next) => {
338
338
  const { actionname } = req.params;
@@ -361,7 +361,7 @@ router.post(
361
361
  const resp = await action.run({
362
362
  configuration: trigger.configuration,
363
363
  body: req.body,
364
- row: req.body,
364
+ row: req.method === "GET" ? req.query : req.body,
365
365
  req,
366
366
  user: user || req.user,
367
367
  });
@@ -9,7 +9,7 @@ const {
9
9
  post_dropdown_item,
10
10
  } = require("@saltcorn/markup");
11
11
  const { get_base_url } = require("./utils.js");
12
- const { h4, p, div, a, input, text } = require("@saltcorn/markup/tags");
12
+ const { h4, p, div, a, i, input, text } = require("@saltcorn/markup/tags");
13
13
 
14
14
  /**
15
15
  * @param {string} col
@@ -344,7 +344,11 @@ const getPageList = (rows, roles, req, { tagId, domId, showList } = {}) => {
344
344
  },
345
345
  {
346
346
  label: req.__("Edit"),
347
- key: (r) => link(`/pageedit/edit/${r.name}`, req.__("Edit")),
347
+ key: (r) =>
348
+ link(
349
+ `/pageedit/${!r.html_file ? "edit" : "edit-properties"}/${r.name}`,
350
+ req.__(!r.html_file ? "Edit" : "Edit properties")
351
+ ),
348
352
  },
349
353
  !tagId
350
354
  ? {
@@ -385,10 +389,16 @@ const getTriggerList = (triggers, req, { tagId, domId, showList } = {}) => {
385
389
  },
386
390
  {
387
391
  label: req.__("When"),
388
- key: (a) =>
389
- a.when_trigger === "API call"
390
- ? `API: ${base_url}api/action/${a.name}`
391
- : a.when_trigger,
392
+ key: (act) =>
393
+ act.when_trigger +
394
+ (act.when_trigger === "API call"
395
+ ? a(
396
+ {
397
+ href: `javascript:ajax_modal('/admin/help/API%20actions?name=${act.name}')`,
398
+ },
399
+ i({ class: "fas fa-question-circle ms-1" })
400
+ )
401
+ : ""),
392
402
  },
393
403
  {
394
404
  label: req.__("Test run"),
@@ -21,7 +21,7 @@ const { get_latest_npm_version } = require("@saltcorn/data/models/config");
21
21
  const packagejson = require("../package.json");
22
22
  const Trigger = require("@saltcorn/data/models/trigger");
23
23
  const { fileUploadForm } = require("../markup/forms");
24
- const { get_base_url } = require("./utils.js");
24
+ const { get_base_url, sendHtmlFile } = require("./utils.js");
25
25
 
26
26
  /**
27
27
  * Tables List
@@ -262,10 +262,16 @@ const actionsTab = async (req, triggers) => {
262
262
  },
263
263
  {
264
264
  label: req.__("When"),
265
- key: (a) =>
266
- a.when_trigger === "API call"
267
- ? `API: ${base_url}api/action/${a.name}`
268
- : a.when_trigger,
265
+ key: (act) =>
266
+ act.when_trigger +
267
+ (act.when_trigger === "API call"
268
+ ? a(
269
+ {
270
+ href: `javascript:ajax_modal('/admin/help/API%20actions?name=${act.name}')`,
271
+ },
272
+ i({ class: "fas fa-question-circle ms-1" })
273
+ )
274
+ : ""),
269
275
  },
270
276
  ],
271
277
  triggers
@@ -474,15 +480,16 @@ const get_config_response = async (role_id, res, req) => {
474
480
 
475
481
  if (db_page) {
476
482
  const contents = await db_page.run(req.query, { res, req });
477
-
478
- res.sendWrap(
479
- {
480
- title: db_page.title,
481
- description: db_page.description,
482
- bodyClass: "page_" + db.sqlsanitize(homeCfg),
483
- },
484
- contents
485
- );
483
+ if (contents.html_file) await sendHtmlFile(req, res, contents.html_file);
484
+ else
485
+ res.sendWrap(
486
+ {
487
+ title: db_page.title,
488
+ description: db_page.description,
489
+ bodyClass: "page_" + db.sqlsanitize(homeCfg),
490
+ },
491
+ contents
492
+ );
486
493
  } else res.redirect(homeCfg);
487
494
  return true;
488
495
  }
package/routes/page.js CHANGED
@@ -8,11 +8,13 @@ const Router = require("express-promise-router");
8
8
 
9
9
  const Page = require("@saltcorn/data/models/page");
10
10
  const Trigger = require("@saltcorn/data/models/trigger");
11
+ const File = require("@saltcorn/data/models/file");
11
12
  const { getState } = require("@saltcorn/data/db/state");
12
13
  const {
13
14
  error_catcher,
14
15
  scan_for_page_title,
15
16
  isAdmin,
17
+ sendHtmlFile,
16
18
  } = require("../routes/utils.js");
17
19
  const { add_edit_bar } = require("../markup/admin.js");
18
20
  const { traverseSync } = require("@saltcorn/data/models/layout");
@@ -56,21 +58,23 @@ router.get(
56
58
  name: pagename,
57
59
  render_time: ms,
58
60
  });
59
- res.sendWrap(
60
- {
61
- title,
62
- description: db_page.description,
63
- bodyClass: "page_" + db.sqlsanitize(pagename),
64
- no_menu: db_page.attributes?.no_menu,
65
- } || `${pagename} page`,
66
- add_edit_bar({
67
- role,
68
- title: db_page.name,
69
- what: req.__("Page"),
70
- url: `/pageedit/edit/${encodeURIComponent(db_page.name)}`,
71
- contents,
72
- })
73
- );
61
+ if (contents.html_file) await sendHtmlFile(req, res, contents.html_file);
62
+ else
63
+ res.sendWrap(
64
+ {
65
+ title,
66
+ description: db_page.description,
67
+ bodyClass: "page_" + db.sqlsanitize(pagename),
68
+ no_menu: db_page.attributes?.no_menu,
69
+ } || `${pagename} page`,
70
+ add_edit_bar({
71
+ role,
72
+ title: db_page.name,
73
+ what: req.__("Page"),
74
+ url: `/pageedit/edit/${encodeURIComponent(db_page.name)}`,
75
+ contents,
76
+ })
77
+ );
74
78
  } else {
75
79
  if (db_page && !req.user) {
76
80
  res.redirect(`/auth/login?dest=${encodeURIComponent(req.originalUrl)}`);
@@ -27,6 +27,7 @@ const {
27
27
  addOnDoneRedirect,
28
28
  is_relative_url,
29
29
  } = require("./utils.js");
30
+ const { asyncMap } = require("@saltcorn/data/utils");
30
31
  const {
31
32
  mkTable,
32
33
  renderForm,
@@ -39,6 +40,7 @@ const {
39
40
  } = require("@saltcorn/markup");
40
41
  const { getActionConfigFields } = require("@saltcorn/data/plugin-helper");
41
42
  const Library = require("@saltcorn/data/models/library");
43
+ const path = require("path");
42
44
 
43
45
  /**
44
46
  * @type {object}
@@ -58,6 +60,20 @@ module.exports = router;
58
60
  const pagePropertiesForm = async (req, isNew) => {
59
61
  const roles = await User.get_roles();
60
62
  const pages = (await Page.find()).map((p) => p.name);
63
+ const htmlFiles = await File.find(
64
+ {
65
+ mime_super: "text",
66
+ mime_sub: "html",
67
+ },
68
+ { recursive: true }
69
+ );
70
+ const htmlOptions = await asyncMap(htmlFiles, async (f) => {
71
+ return {
72
+ label: path.join(f.current_folder, f.filename),
73
+ value: File.absPathToServePath(f.location),
74
+ };
75
+ });
76
+
61
77
  const form = new Form({
62
78
  action: addOnDoneRedirect("/pageedit/edit-properties", req),
63
79
  fields: [
@@ -92,6 +108,24 @@ const pagePropertiesForm = async (req, isNew) => {
92
108
  input_type: "select",
93
109
  options: roles.map((r) => ({ value: r.id, label: r.role })),
94
110
  },
111
+ ...(htmlOptions.length > 0
112
+ ? [
113
+ {
114
+ name: "html_file",
115
+ label: req.__("HTML file"),
116
+ sublabel: req.__("HTML file to use as page content"),
117
+ input_type: "select",
118
+
119
+ options: [
120
+ {
121
+ label: req.__("None - use drag and drop builder"),
122
+ value: "",
123
+ },
124
+ ...htmlOptions,
125
+ ],
126
+ },
127
+ ]
128
+ : []),
95
129
  {
96
130
  name: "no_menu",
97
131
  label: req.__("No menu"),
@@ -289,7 +323,7 @@ const wrap = (contents, noCard, req, page) => ({
289
323
  crumbs: [
290
324
  { text: req.__("Pages"), href: "/pageedit" },
291
325
  page
292
- ? { href: `/pageedit/edit/${page.name}`, text: page.name }
326
+ ? { href: `/page/${page.name}`, text: page.name }
293
327
  : { text: req.__("New") },
294
328
  ],
295
329
  },
@@ -367,17 +401,30 @@ router.post(
367
401
  wrap(renderForm(form, req.csrfToken()), false, req)
368
402
  );
369
403
  } else {
370
- const { id, columns, no_menu, ...pageRow } = form.values;
404
+ const { id, columns, no_menu, html_file, ...pageRow } = form.values;
371
405
  pageRow.min_role = +pageRow.min_role;
372
406
  pageRow.attributes = { no_menu };
407
+ if (html_file) {
408
+ pageRow.layout = {
409
+ html_file: html_file,
410
+ };
411
+ }
373
412
  if (+id) {
413
+ const dbPage = Page.findOne({ id: id });
414
+ if (dbPage.layout?.html_file && !html_file) {
415
+ pageRow.layout = {};
416
+ }
374
417
  await Page.update(+id, pageRow);
375
418
  res.redirect(`/pageedit/`);
376
419
  } else {
377
- if (!pageRow.fixed_states) pageRow.fixed_states = {};
378
420
  if (!pageRow.layout) pageRow.layout = {};
421
+ if (!pageRow.fixed_states) pageRow.fixed_states = {};
379
422
  await Page.create(pageRow);
380
- res.redirect(addOnDoneRedirect(`/pageedit/edit/${pageRow.name}`, req));
423
+ if (!html_file)
424
+ res.redirect(
425
+ addOnDoneRedirect(`/pageedit/edit/${pageRow.name}`, req)
426
+ );
427
+ else res.redirect(`/pageedit/`);
381
428
  }
382
429
  }
383
430
  })
package/routes/utils.js CHANGED
@@ -18,6 +18,7 @@ const cookieSession = require("cookie-session");
18
18
  const is = require("contractis/is");
19
19
  const { validateHeaderName, validateHeaderValue } = require("http");
20
20
  const Crash = require("@saltcorn/data/models/crash");
21
+ const File = require("@saltcorn/data/models/file");
21
22
  const si = require("systeminformation");
22
23
  const {
23
24
  config_fields_form,
@@ -25,6 +26,8 @@ const {
25
26
  check_if_restart_required,
26
27
  flash_restart,
27
28
  } = require("../markup/admin.js");
29
+ const path = require("path");
30
+
28
31
  const get_sys_info = async () => {
29
32
  const disks = await si.fsSize();
30
33
  let size = 0;
@@ -380,6 +383,38 @@ const admin_config_route = ({
380
383
  );
381
384
  };
382
385
 
386
+ /**
387
+ * Send HTML file to client without any menu
388
+ * @param {any} req
389
+ * @param {any} res
390
+ * @param {string} file
391
+ * @returns
392
+ */
393
+ const sendHtmlFile = async (req, res, file) => {
394
+ const fullPath = path.join((await File.rootFolder()).location, file);
395
+ const role = req.user && req.user.id ? req.user.role_id : 100;
396
+ try {
397
+ const scFile = await File.from_file_on_disk(
398
+ path.basename(fullPath),
399
+ path.dirname(fullPath)
400
+ );
401
+ if (scFile && role <= scFile.min_role_read) {
402
+ res.sendFile(fullPath);
403
+ } else {
404
+ return res
405
+ .status(404)
406
+ .sendWrap(req.__("An error occurred"), req.__("File not found"));
407
+ }
408
+ } catch (e) {
409
+ return res
410
+ .status(404)
411
+ .sendWrap(
412
+ req.__("An error occurred"),
413
+ e.message || req.__("An error occurred")
414
+ );
415
+ }
416
+ };
417
+
383
418
  module.exports = {
384
419
  sqlsanitize,
385
420
  csrfField,
@@ -396,4 +431,5 @@ module.exports = {
396
431
  is_relative_url,
397
432
  get_sys_info,
398
433
  admin_config_route,
434
+ sendHtmlFile,
399
435
  };
package/tests/api.test.js CHANGED
@@ -1,6 +1,9 @@
1
1
  const request = require("supertest");
2
2
  const getApp = require("../app");
3
3
  const Table = require("@saltcorn/data/models/table");
4
+ const Trigger = require("@saltcorn/data/models/trigger");
5
+
6
+ const Field = require("@saltcorn/data/models/field");
4
7
  const {
5
8
  getStaffLoginCookie,
6
9
  getAdminLoginCookie,
@@ -9,6 +12,7 @@ const {
9
12
  succeedJsonWith,
10
13
  notAuthorized,
11
14
  toRedirect,
15
+ succeedJsonWithWholeBody,
12
16
  } = require("../auth/testhelp");
13
17
  const db = require("@saltcorn/data/db");
14
18
  const User = require("@saltcorn/data/models/user");
@@ -322,3 +326,66 @@ describe("API authentication", () => {
322
326
  .expect(succeedJsonWith((rows) => rows.length == 2));
323
327
  });
324
328
  });
329
+
330
+ describe("API action", () => {
331
+ it("should set up trigger", async () => {
332
+ const table = await Table.create("triggercounter");
333
+ await Field.create({
334
+ table,
335
+ name: "thing",
336
+ label: "TheThing",
337
+ type: "String",
338
+ });
339
+ await Trigger.create({
340
+ action: "run_js_code",
341
+ when_trigger: "API call",
342
+ name: "mywebhook",
343
+ min_role: 100,
344
+ configuration: {
345
+ code: `
346
+ const table = Table.findOne({ name: "triggercounter" });
347
+ await table.insertRow({ thing: row?.thing || "no body" });
348
+ return {studio: 54}
349
+ `,
350
+ },
351
+ });
352
+ });
353
+ it("should POST to trigger", async () => {
354
+ const app = await getApp({ disableCsrf: true });
355
+ await request(app)
356
+ .post("/api/action/mywebhook")
357
+ .send({
358
+ thing: "inthebody",
359
+ })
360
+ .set("Content-Type", "application/json")
361
+ .set("Accept", "application/json")
362
+ .expect(succeedJsonWithWholeBody((resp) => resp?.data?.studio === 54));
363
+ const table = Table.findOne({ name: "triggercounter" });
364
+ const counts = await table.getRows({});
365
+ expect(counts.map((c) => c.thing)).toContain("inthebody");
366
+ expect(counts.map((c) => c.thing)).not.toContain("no body");
367
+ });
368
+ it("should GET with query to trigger", async () => {
369
+ const app = await getApp({ disableCsrf: true });
370
+ await request(app)
371
+ .get("/api/action/mywebhook?thing=inthequery")
372
+ .set("Content-Type", "application/json")
373
+ .set("Accept", "application/json")
374
+ .expect(succeedJsonWithWholeBody((resp) => resp?.data?.studio === 54));
375
+ const table = Table.findOne({ name: "triggercounter" });
376
+ const counts = await table.getRows({});
377
+ expect(counts.map((c) => c.thing)).toContain("inthequery");
378
+ expect(counts.map((c) => c.thing)).not.toContain("no body");
379
+ });
380
+ it("should GET to trigger", async () => {
381
+ const app = await getApp({ disableCsrf: true });
382
+ await request(app)
383
+ .get("/api/action/mywebhook")
384
+ .set("Content-Type", "application/json")
385
+ .set("Accept", "application/json")
386
+ .expect(succeedJsonWithWholeBody((resp) => resp?.data?.studio === 54));
387
+ const table = Table.findOne({ name: "triggercounter" });
388
+ const counts = await table.getRows({});
389
+ expect(counts.map((c) => c.thing)).toContain("no body");
390
+ });
391
+ });
@@ -13,9 +13,42 @@ const {
13
13
  } = require("../auth/testhelp");
14
14
  const db = require("@saltcorn/data/db");
15
15
  const Page = require("@saltcorn/data/models/page");
16
+ const File = require("@saltcorn/data/models/file");
17
+ const { existsSync } = require("fs");
18
+ const { join } = require("path");
19
+
20
+ let htmlFile = null;
21
+
22
+ const prepHtmlFiles = async () => {
23
+ const createFile = async (folder, name, content) => {
24
+ const scFolder = join(
25
+ db.connectObj.file_store,
26
+ db.getTenantSchema(),
27
+ folder
28
+ );
29
+ if (!existsSync(scFolder)) await File.new_folder(folder);
30
+ if (!existsSync(join(scFolder, name))) {
31
+ return await File.from_contents(
32
+ name,
33
+ "text/html",
34
+ `<html><head><title>Landing page</title></head><body><h1>${content}</h1></body></html>`,
35
+ 1,
36
+ 1,
37
+ folder
38
+ );
39
+ } else {
40
+ const file = await File.from_file_on_disk(name, scFolder);
41
+ file.location = File.absPathToServePath(file.location);
42
+ return file;
43
+ }
44
+ };
45
+ htmlFile = await createFile("/", "fixed_page.html", "Land here");
46
+ await createFile("/subfolder", "fixed_page2.html", "Or Land here");
47
+ };
16
48
 
17
49
  beforeAll(async () => {
18
50
  await resetToFixtures();
51
+ await prepHtmlFiles();
19
52
  });
20
53
  afterAll(db.close);
21
54
 
@@ -36,9 +69,18 @@ describe("page create", () => {
36
69
  await request(app)
37
70
  .get("/pageedit/new")
38
71
  .set("Cookie", loginCookie)
39
-
40
72
  .expect(toInclude("A short name that will be in your URL"));
41
73
  });
74
+ it("shows new with html file selector", async () => {
75
+ const app = await getApp({ disableCsrf: true });
76
+ const loginCookie = await getAdminLoginCookie();
77
+ await request(app)
78
+ .get("/pageedit/new")
79
+ .set("Cookie", loginCookie)
80
+ .expect(toInclude("HTML file"))
81
+ .expect(toInclude("fixed_page.html"))
82
+ .expect(toInclude(join("subfolder", "fixed_page2.html")));
83
+ });
42
84
  it("fills basic details", async () => {
43
85
  const app = await getApp({ disableCsrf: true });
44
86
  const loginCookie = await getAdminLoginCookie();
@@ -48,6 +90,19 @@ describe("page create", () => {
48
90
  .set("Cookie", loginCookie)
49
91
  .expect(toRedirect("/pageedit/edit/whales"));
50
92
  });
93
+ it("fills details with html-file", async () => {
94
+ const app = await getApp({ disableCsrf: true });
95
+ const loginCookie = await getAdminLoginCookie();
96
+ await request(app)
97
+ .post("/pageedit/edit-properties")
98
+ .send(
99
+ `name=new_page_with_html_file&title=foo&description=bar&min_role=100&html_file=${encodeURIComponent(
100
+ htmlFile.location
101
+ )}`
102
+ )
103
+ .set("Cookie", loginCookie)
104
+ .expect(toRedirect("/pageedit/"));
105
+ });
51
106
  it("fills layout", async () => {
52
107
  const app = await getApp({ disableCsrf: true });
53
108
  const loginCookie = await getAdminLoginCookie();
@@ -68,6 +123,44 @@ describe("page create", () => {
68
123
  .set("Cookie", loginCookie)
69
124
  .expect(toInclude("Herman"));
70
125
  });
126
+
127
+ it("shows page with html file", async () => {
128
+ const app = await getApp({ disableCsrf: true });
129
+ const loginCookie = await getAdminLoginCookie();
130
+ await request(app)
131
+ .get("/page/new_page_with_html_file")
132
+ .set("Cookie", loginCookie)
133
+ .expect(toInclude("Land here"));
134
+ });
135
+
136
+ it("does not find the html file for staff or public", async () => {
137
+ const app = await getApp({ disableCsrf: true });
138
+ const loginCookie = await getStaffLoginCookie();
139
+ await request(app)
140
+ .get("/page/new_page_with_html_file")
141
+ .set("Cookie", loginCookie)
142
+ .expect(toInclude("not found", 404));
143
+ await request(app)
144
+ .get("/page/new_page_with_html_file")
145
+ .expect(toInclude("not found", 404));
146
+ });
147
+
148
+ it("finds the html file for staff (after update)", async () => {
149
+ const app = await getApp({ disableCsrf: true });
150
+ await request(app)
151
+ .post("/files/setrole/fixed_page.html")
152
+ .set("Cookie", await getAdminLoginCookie())
153
+ .send("role=40")
154
+ .expect(toRedirect("/files?dir=."));
155
+ const loginCookie = await getStaffLoginCookie();
156
+ await request(app)
157
+ .get("/page/new_page_with_html_file")
158
+ .set("Cookie", loginCookie)
159
+ .expect(toInclude("Land here"));
160
+ await request(app)
161
+ .get("/page/new_page_with_html_file")
162
+ .expect(toInclude("not found", 404));
163
+ });
71
164
  });
72
165
 
73
166
  describe("page action", () => {