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 +7 -1
- package/lib/sql-builders.js +118 -1
- package/lib/view-context.js +34 -10
- package/package.json +1 -1
- package/routes/import.js +2 -0
package/README.md
CHANGED
|
@@ -27,7 +27,13 @@ TODO
|
|
|
27
27
|

|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
##
|
|
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
|
|
package/lib/sql-builders.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/lib/view-context.js
CHANGED
|
@@ -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, "&")
|
|
13
|
+
.replace(/</g, "<")
|
|
14
|
+
.replace(/>/g, ">")
|
|
15
|
+
.replace(/"/g, """)
|
|
16
|
+
.replace(/'/g, "'");
|
|
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
|
-
|
|
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 ? `${
|
|
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
|
-
|
|
30
|
-
return
|
|
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
|
-
|
|
60
|
+
const safeViewBaseUrl = sanitizeViewBaseUrl(viewBaseUrl);
|
|
61
|
+
if (!safeViewBaseUrl) return routePath;
|
|
39
62
|
const sep = routePath.includes("?") ? "&" : "?";
|
|
40
|
-
return `${routePath}${sep}view_base_url=${encodeURIComponent(
|
|
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
|
-
|
|
48
|
-
|
|
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
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) {
|