saltcorn-db-code 0.1.0 → 0.2.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/README.md CHANGED
@@ -27,7 +27,13 @@ TODO
27
27
  ![Test functions screen](./screenshots/test_functions.png)
28
28
 
29
29
 
30
- ## Local installation during development
30
+ ## Installation
31
+
32
+ You can install directly from your saltcorn instance.
33
+ - Go to Settings - Modules
34
+ - Choose "Add another module" under top right dropdown menu.
35
+ - Fill name, source (npm) and saltcorn-db-code as Location.
36
+ - Create a View with View Pattern DBCodeConsole
31
37
 
32
38
  From the Saltcorn checkout:
33
39
 
@@ -1,5 +1,118 @@
1
1
  const { assertIdentifier, assertAllowed, ALLOWED_LANGUAGES, ALLOWED_VOLATILITY, ALLOWED_SECURITY } = require("./validation");
2
2
 
3
+ function escapeRegex(value) {
4
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
+ }
6
+
7
+ function hasExactlyOneTopLevelTerminatedStatement(sql) {
8
+ const text = String(sql || "");
9
+ let i = 0;
10
+ let inSingle = false;
11
+ let inDouble = false;
12
+ let inLineComment = false;
13
+ let inBlockComment = false;
14
+ let dollarTag = null;
15
+ let semicolonCount = 0;
16
+
17
+ while (i < text.length) {
18
+ const c = text[i];
19
+ const next = text[i + 1];
20
+
21
+ if (inLineComment) {
22
+ if (c === "\n") inLineComment = false;
23
+ i += 1;
24
+ continue;
25
+ }
26
+
27
+ if (inBlockComment) {
28
+ if (c === "*" && next === "/") {
29
+ inBlockComment = false;
30
+ i += 2;
31
+ } else {
32
+ i += 1;
33
+ }
34
+ continue;
35
+ }
36
+
37
+ if (dollarTag) {
38
+ if (text.startsWith(dollarTag, i)) {
39
+ i += dollarTag.length;
40
+ dollarTag = null;
41
+ } else {
42
+ i += 1;
43
+ }
44
+ continue;
45
+ }
46
+
47
+ if (inSingle) {
48
+ if (c === "'" && next === "'") {
49
+ i += 2;
50
+ } else if (c === "'") {
51
+ inSingle = false;
52
+ i += 1;
53
+ } else {
54
+ i += 1;
55
+ }
56
+ continue;
57
+ }
58
+
59
+ if (inDouble) {
60
+ if (c === '"' && next === '"') {
61
+ i += 2;
62
+ } else if (c === '"') {
63
+ inDouble = false;
64
+ i += 1;
65
+ } else {
66
+ i += 1;
67
+ }
68
+ continue;
69
+ }
70
+
71
+ if (c === "-" && next === "-") {
72
+ inLineComment = true;
73
+ i += 2;
74
+ continue;
75
+ }
76
+
77
+ if (c === "/" && next === "*") {
78
+ inBlockComment = true;
79
+ i += 2;
80
+ continue;
81
+ }
82
+
83
+ if (c === "$") {
84
+ const match = text.slice(i).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/);
85
+ if (match) {
86
+ dollarTag = match[0];
87
+ i += dollarTag.length;
88
+ continue;
89
+ }
90
+ }
91
+
92
+ if (c === "'") {
93
+ inSingle = true;
94
+ i += 1;
95
+ continue;
96
+ }
97
+
98
+ if (c === '"') {
99
+ inDouble = true;
100
+ i += 1;
101
+ continue;
102
+ }
103
+
104
+ if (c === ";") semicolonCount += 1;
105
+ i += 1;
106
+ }
107
+
108
+ if (inSingle || inDouble || inBlockComment || inLineComment || dollarTag) return false;
109
+ if (semicolonCount !== 1) return false;
110
+
111
+ const trimmed = text.trim();
112
+ if (!trimmed.endsWith(";")) return false;
113
+ return true;
114
+ }
115
+
3
116
  function quoteIdent(identifier) {
4
117
  assertIdentifier(identifier);
5
118
  return `"${identifier.replace(/"/g, '""')}"`;
@@ -54,10 +167,14 @@ function buildDropRoutineSql({ schema, name, identityArguments = "", kind = "fun
54
167
  function validateCreateRoutineDdl(sql, schema) {
55
168
  const ddl = String(sql || "").trim();
56
169
  if (!ddl) throw new Error("DDL is required.");
170
+ assertIdentifier(schema, "schema");
171
+ if (!hasExactlyOneTopLevelTerminatedStatement(ddl)) {
172
+ throw new Error("DDL must contain exactly one CREATE FUNCTION/PROCEDURE statement terminated by a single top-level semicolon.");
173
+ }
57
174
  if (!/^CREATE\s+(OR\s+REPLACE\s+)?(FUNCTION|PROCEDURE)\s+/i.test(ddl)) {
58
175
  throw new Error("DDL must start with CREATE FUNCTION, CREATE OR REPLACE FUNCTION, CREATE PROCEDURE, or CREATE OR REPLACE PROCEDURE.");
59
176
  }
60
- const schemaPattern = new RegExp(`^CREATE\\s+(OR\\s+REPLACE\\s+)?(FUNCTION|PROCEDURE)\\s+("${schema.replace(/"/g, '""')}"|${schema})\\.`, "i");
177
+ const schemaPattern = new RegExp(`^CREATE\\s+(OR\\s+REPLACE\\s+)?(FUNCTION|PROCEDURE)\\s+("${escapeRegex(schema)}"|${escapeRegex(schema)})\\s*\\.`, "i");
61
178
  if (!schemaPattern.test(ddl)) throw new Error(`DDL must create the routine explicitly in the current tenant schema: ${schema}.`);
62
179
  return ddl;
63
180
  }
@@ -7,27 +7,49 @@
7
7
  * is null and all links use the /db-code paths as before.
8
8
  */
9
9
 
10
+ function escapeAttr(value) {
11
+ return String(value ?? "")
12
+ .replace(/&/g, "&amp;")
13
+ .replace(/</g, "&lt;")
14
+ .replace(/>/g, "&gt;")
15
+ .replace(/"/g, "&quot;")
16
+ .replace(/'/g, "&#39;");
17
+ }
18
+
19
+ function sanitizeViewBaseUrl(value) {
20
+ const text = String(value || "").trim();
21
+ if (!text) return null;
22
+ if (!text.startsWith("/")) return null;
23
+ if (text.startsWith("//")) return null;
24
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(text)) return null;
25
+ if (!/^\/(view|db-code)(\/|$)/.test(text)) return null;
26
+ if (/[\r\n]/.test(text)) return null;
27
+ return text;
28
+ }
29
+
10
30
  /**
11
31
  * Build the list-page URL for the current context.
12
32
  * @param {string|null} viewBaseUrl
13
33
  * @param {string} kind Optional kind filter ("function" or "procedure")
14
34
  */
15
35
  function listViewHref(viewBaseUrl, kind) {
16
- if (!viewBaseUrl) {
36
+ const safeViewBaseUrl = sanitizeViewBaseUrl(viewBaseUrl);
37
+ if (!safeViewBaseUrl) {
17
38
  return kind ? `/db-code?kind=${encodeURIComponent(kind)}` : "/db-code";
18
39
  }
19
40
  const params = new URLSearchParams();
20
41
  if (kind) params.set("kind", kind);
21
42
  const qs = params.toString();
22
- return qs ? `${viewBaseUrl}?${qs}` : viewBaseUrl;
43
+ return qs ? `${safeViewBaseUrl}?${qs}` : safeViewBaseUrl;
23
44
  }
24
45
 
25
46
  /**
26
47
  * Build the detail-page URL for the current context.
27
48
  */
28
49
  function detailViewHref(viewBaseUrl, oid) {
29
- if (!viewBaseUrl) return `/db-code/routine/${oid}`;
30
- return `${viewBaseUrl}?routine_oid=${oid}`;
50
+ const safeViewBaseUrl = sanitizeViewBaseUrl(viewBaseUrl);
51
+ if (!safeViewBaseUrl) return `/db-code/routine/${oid}`;
52
+ return `${safeViewBaseUrl}?routine_oid=${oid}`;
31
53
  }
32
54
 
33
55
  /**
@@ -35,24 +57,26 @@ function detailViewHref(viewBaseUrl, oid) {
35
57
  * the user is in a view context so the route can redirect back.
36
58
  */
37
59
  function routeHref(routePath, viewBaseUrl) {
38
- if (!viewBaseUrl) return routePath;
60
+ const safeViewBaseUrl = sanitizeViewBaseUrl(viewBaseUrl);
61
+ if (!safeViewBaseUrl) return routePath;
39
62
  const sep = routePath.includes("?") ? "&" : "?";
40
- return `${routePath}${sep}view_base_url=${encodeURIComponent(viewBaseUrl)}`;
63
+ return `${routePath}${sep}view_base_url=${encodeURIComponent(safeViewBaseUrl)}`;
41
64
  }
42
65
 
43
66
  /**
44
67
  * Hidden form input that carries view_base_url through POST requests.
45
68
  */
46
69
  function viewContextInput(viewBaseUrl) {
47
- if (!viewBaseUrl) return "";
48
- return `<input type="hidden" name="view_base_url" value="${viewBaseUrl.replace(/"/g, "&quot;")}">`;
70
+ const safeViewBaseUrl = sanitizeViewBaseUrl(viewBaseUrl);
71
+ if (!safeViewBaseUrl) return "";
72
+ return `<input type="hidden" name="view_base_url" value="${escapeAttr(safeViewBaseUrl)}">`;
49
73
  }
50
74
 
51
75
  /**
52
76
  * Read view_base_url from a request (query on GET, body on POST).
53
77
  */
54
78
  function getViewBaseUrl(req) {
55
- return req.query?.view_base_url || req.body?.view_base_url || null;
79
+ return sanitizeViewBaseUrl(req.query?.view_base_url || req.body?.view_base_url || null);
56
80
  }
57
81
 
58
- module.exports = { listViewHref, detailViewHref, routeHref, viewContextInput, getViewBaseUrl };
82
+ module.exports = { listViewHref, detailViewHref, routeHref, viewContextInput, getViewBaseUrl, sanitizeViewBaseUrl };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saltcorn-db-code",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Saltcorn plugin to inspect and manage PostgreSQL database routines (functions and stored procedures) from the Saltcorn UI.",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/routes/import.js CHANGED
@@ -2,6 +2,7 @@ const { requireAdmin } = require("../lib/auth");
2
2
  const { escapeHtml, page } = require("../lib/html");
3
3
  const { ensurePostgres, currentTenantSchema } = require("../lib/introspection");
4
4
  const { validateImportPack, remapSchemaInDdl, renderPackPreview } = require("../lib/export-import");
5
+ const { validateCreateRoutineDdl } = require("../lib/sql-builders");
5
6
  const { listViewHref, viewContextInput, getViewBaseUrl } = require("../lib/view-context");
6
7
 
7
8
  const db = require("@saltcorn/data/db");
@@ -206,6 +207,7 @@ async function importExecuteRoute(req, res) {
206
207
  if (validation.pack.source_schema && validation.pack.source_schema !== schema) {
207
208
  ddl = remapSchemaInDdl(ddl, validation.pack.source_schema, schema);
208
209
  }
210
+ ddl = validateCreateRoutineDdl(ddl, schema);
209
211
  await db.query(ddl);
210
212
  results.push({ name: r.name, kind: r.kind, status: "ok" });
211
213
  } catch (err) {