@galadrim/galadmin 0.1.235 → 0.2.6
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/.github/workflows/main.yml +33 -2
- package/README.md +65 -0
- package/dist/api.d.ts +16 -0
- package/dist/api.js +276 -215
- package/dist/api.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utils/dbManager.d.ts +6 -0
- package/dist/utils/dbManager.js +150 -10
- package/dist/utils/dbManager.js.map +1 -1
- package/dist/utils/imageDimensions.d.ts +5 -0
- package/dist/utils/imageDimensions.js +98 -0
- package/dist/utils/imageDimensions.js.map +1 -0
- package/jest.config.js +43 -0
- package/jest.integration.config.js +16 -0
- package/package.json +30 -10
- package/src/api.ts +403 -303
- package/src/front/ckeditor5-build/node_modules/.bin/semver +12 -0
- package/src/front/ckeditor5-build/node_modules/.bin/semver.cmd +17 -0
- package/src/front/ckeditor5-build/node_modules/.bin/semver.ps1 +28 -0
- package/src/front/ckeditor5-build/node_modules/semver/bin/semver.js +0 -0
- package/src/front/src/App.test.js +28 -0
- package/src/front/src/components/OutsideClickHandler.test.js +53 -0
- package/src/front/src/components/RedirectToFirstView.test.js +38 -0
- package/src/front/src/utils/checkRequiredColumns.test.js +46 -0
- package/src/front/src/utils/evalExpression.test.js +29 -0
- package/src/index.ts +1 -1
- package/src/utils/dbManager.ts +248 -102
- package/src/utils/imageDimensions.ts +112 -0
- package/tests/__mocks__/@aws-sdk/client-s3.js +6 -0
- package/tests/__mocks__/@aws-sdk/s3-request-presigner.js +3 -0
- package/tests/__mocks__/@google-cloud/storage.js +7 -0
- package/tests/__mocks__/argon2.js +3 -0
- package/tests/__mocks__/axios.js +7 -0
- package/tests/__mocks__/connect-pg-simple.js +24 -0
- package/tests/__mocks__/express-mysql-session.js +24 -0
- package/tests/__mocks__/mysql2/promise.js +3 -0
- package/tests/__mocks__/pg.js +3 -0
- package/tests/__mocks__/rollup.js +5 -0
- package/tests/__mocks__/ssh2-sftp-client.js +10 -0
- package/tests/api-helpers.test.ts +376 -0
- package/tests/dbManager.init.test.ts +129 -0
- package/tests/dbManager.test.ts +265 -0
- package/tests/edge-cases.test.ts +167 -0
- package/tests/helpers/app.ts +69 -0
- package/tests/helpers/fakeDb.ts +66 -0
- package/tests/helpers/fakePool.ts +76 -0
- package/tests/helpers/fixtures.ts +68 -0
- package/tests/imageDimensions.test.ts +111 -0
- package/tests/index.sessionStore.test.ts +19 -0
- package/tests/index.test.ts +363 -0
- package/tests/integration/__mocks__/axios.js +3 -0
- package/tests/integration/dbHarness.ts +381 -0
- package/tests/integration/dbManager.integration.test.ts +69 -0
- package/tests/integration/globalSetup.ts +43 -0
- package/tests/integration/globalTeardown.ts +42 -0
- package/tests/integration/routes.authorization.integration.test.ts +319 -0
- package/tests/integration/routes.config.integration.test.ts +189 -0
- package/tests/integration/routes.crud.integration.test.ts +805 -0
- package/tests/integration/routes.custom.integration.test.ts +376 -0
- package/tests/integration/routes.foreign.integration.test.ts +306 -0
- package/tests/integration/routes.importExport.integration.test.ts +404 -0
- package/tests/integration/routes.integration.test.ts +366 -0
- package/tests/integration/routes.postgresql.integration.test.ts +88 -0
- package/tests/integration/routes.records.integration.test.ts +634 -0
- package/tests/integration/routes.upload.integration.test.ts +425 -0
- package/tests/setup/silence-console.ts +15 -0
package/src/api.ts
CHANGED
|
@@ -8,7 +8,7 @@ import * as mime from "mime";
|
|
|
8
8
|
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
9
9
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
10
10
|
import { Storage } from "@google-cloud/storage";
|
|
11
|
-
import
|
|
11
|
+
import { getImageDimensions } from "./utils/imageDimensions";
|
|
12
12
|
import * as serializeJs from "serialize-javascript";
|
|
13
13
|
import * as SftpClient from "ssh2-sftp-client";
|
|
14
14
|
|
|
@@ -18,12 +18,189 @@ import resolve from "@rollup/plugin-node-resolve";
|
|
|
18
18
|
import axios from "axios";
|
|
19
19
|
import { v4 as uuidv4 } from "uuid";
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
import { quoteIdentifier } from "./utils/dbManager";
|
|
22
|
+
|
|
23
|
+
export function generateUniqueIdForObject(obj) {
|
|
22
24
|
const str = JSON.stringify(obj);
|
|
23
25
|
const hash = crypto.createHash("sha256").update(str).digest("hex");
|
|
24
26
|
return hash;
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// SECURITY: server-side authorization.
|
|
31
|
+
//
|
|
32
|
+
// The config-filtering middleware below only decides which views the *client*
|
|
33
|
+
// is shown, and sets canEdit/canCreate/canDelete flags on that served config.
|
|
34
|
+
// Nothing enforced those decisions server-side, so a session with the lowest
|
|
35
|
+
// role could POST directly to /updateRecord, /deleteRecord, /customQuery, …
|
|
36
|
+
// and act on any view — including read-only ones — as the DB user (root).
|
|
37
|
+
// This block re-checks every sensitive route against the view's declared roles.
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
export const ROOT_ROLE = "root";
|
|
40
|
+
export const roleOf = (req) =>
|
|
41
|
+
req.session && req.session.user && req.session.user.role;
|
|
42
|
+
// Write access = the view's `roles` (root always allowed).
|
|
43
|
+
export const canWriteView = (view, role) =>
|
|
44
|
+
role === ROOT_ROLE || !view || !view.roles || view.roles.includes(role);
|
|
45
|
+
|
|
46
|
+
export const viewFromRequest = (req) => {
|
|
47
|
+
const idx =
|
|
48
|
+
req.body && req.body.viewIndex !== undefined
|
|
49
|
+
? req.body.viewIndex
|
|
50
|
+
: req.query && req.query.viewIndex !== undefined
|
|
51
|
+
? req.query.viewIndex
|
|
52
|
+
: undefined;
|
|
53
|
+
if (idx === undefined || !req.config || !req.config.views) return undefined;
|
|
54
|
+
return req.config.views[idx];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Routes that mutate data and reference their target view via viewIndex
|
|
58
|
+
// (body for POST bodies, query for /uploadFile). Import routes carry the
|
|
59
|
+
// viewIndex inside a multipart body and are guarded inline in their handlers.
|
|
60
|
+
export const MUTATING_VIEW_ROUTES = new Set([
|
|
61
|
+
"/updateRecord",
|
|
62
|
+
"/createRecord",
|
|
63
|
+
"/deleteRecord",
|
|
64
|
+
"/deleteRecords",
|
|
65
|
+
"/deleteRecordFromPanel",
|
|
66
|
+
"/customAction",
|
|
67
|
+
"/uploadFile",
|
|
68
|
+
]);
|
|
69
|
+
// /duplicateRecord references its table via req.body.table (not a viewIndex),
|
|
70
|
+
// so it is guarded inline in its handler instead of by the generic middleware.
|
|
71
|
+
|
|
72
|
+
// Column names are interpolated into INSERT/UPDATE statements; only accept plain
|
|
73
|
+
// SQL identifiers so a crafted key cannot break out of the column list.
|
|
74
|
+
export const areSafeColumnNames = (data) =>
|
|
75
|
+
!data || Object.keys(data).every((k) => /^[A-Za-z0-9_]+$/.test(k));
|
|
76
|
+
|
|
77
|
+
// Tables the current role is allowed to write, gathered from its writable views
|
|
78
|
+
// and their creatable panels — used to bound /duplicateRecord's target table.
|
|
79
|
+
export const writableTables = (req) => {
|
|
80
|
+
const role = roleOf(req);
|
|
81
|
+
const tables = new Set<string>();
|
|
82
|
+
for (const view of req.config?.views || []) {
|
|
83
|
+
if (!canWriteView(view, role)) continue;
|
|
84
|
+
if (view.tableName) tables.add(view.tableName);
|
|
85
|
+
for (const panel of view.recordViewPanels || []) {
|
|
86
|
+
if (panel.tableName) tables.add(panel.tableName);
|
|
87
|
+
if (panel.createConfig?.tableName)
|
|
88
|
+
tables.add(panel.createConfig.tableName);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return tables;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Reject client-supplied SQL fragments (the `filters` array is concatenated
|
|
95
|
+
// raw into the WHERE clause). With multipleStatements off this already blocks
|
|
96
|
+
// stacked writes; the pattern also blocks UNION/comment/file exfil breakouts.
|
|
97
|
+
export const looksLikeSqlBreakout = (frag) =>
|
|
98
|
+
typeof frag === "string" &&
|
|
99
|
+
/(;|--|\/\*|\bunion\b|\binto\s+(out|dump)file\b|\bsleep\s*\(|\bbenchmark\s*\(|\bload_file\s*\()/i.test(
|
|
100
|
+
frag,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
export function sqlAnd(conditions) {
|
|
104
|
+
const existingConditions = [];
|
|
105
|
+
for (const condition of conditions) {
|
|
106
|
+
if (condition && condition.length > 0) {
|
|
107
|
+
existingConditions.push(condition);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (existingConditions.length <= 1) {
|
|
111
|
+
return existingConditions[0] || null;
|
|
112
|
+
} else {
|
|
113
|
+
let result = "";
|
|
114
|
+
for (let i = 0; i < existingConditions.length; i++) {
|
|
115
|
+
if (i > 0) {
|
|
116
|
+
result += " AND ";
|
|
117
|
+
}
|
|
118
|
+
result += "(" + existingConditions[i] + ")";
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function getFormattedValue(column, record) {
|
|
125
|
+
let value;
|
|
126
|
+
try {
|
|
127
|
+
record.options = column.options;
|
|
128
|
+
if (column.formatValueForExport) {
|
|
129
|
+
value = await column.formatValueForExport(record);
|
|
130
|
+
} else if (column.formattedValue) {
|
|
131
|
+
value = evalExpression(column.formattedValue, record);
|
|
132
|
+
} else if (column.formatValue) {
|
|
133
|
+
value = await column.formatValue(record);
|
|
134
|
+
} else if (column.dataType == "tinyint") {
|
|
135
|
+
value = record[column.name] == 1 ? "Oui" : "Non";
|
|
136
|
+
} else {
|
|
137
|
+
value = record[column.name];
|
|
138
|
+
}
|
|
139
|
+
return value === null || typeof value == "undefined" ? "" : value;
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return value || "";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function evalExpression(expression, data) {
|
|
146
|
+
let varString = "";
|
|
147
|
+
let statement = "";
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
for (let key in data) {
|
|
151
|
+
statement =
|
|
152
|
+
"var " +
|
|
153
|
+
key +
|
|
154
|
+
" = " +
|
|
155
|
+
(typeof data[key] === "string"
|
|
156
|
+
? "'" +
|
|
157
|
+
data[key]
|
|
158
|
+
.replace(/'/g, "\\'")
|
|
159
|
+
.replace(/\n/g, "")
|
|
160
|
+
.replace(/\r/g, "") +
|
|
161
|
+
"'"
|
|
162
|
+
: typeof data[key] === "object"
|
|
163
|
+
? JSON.stringify(data[key])
|
|
164
|
+
: data[key]) +
|
|
165
|
+
";";
|
|
166
|
+
varString += statement;
|
|
167
|
+
}
|
|
168
|
+
const result = eval(varString + expression);
|
|
169
|
+
return result;
|
|
170
|
+
} catch (e) {
|
|
171
|
+
console.log("Problème d'évaluation de l'expression", statement);
|
|
172
|
+
console.error(e);
|
|
173
|
+
return "Error";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function formatValueForInsertion(value: any, dbType: string) {
|
|
178
|
+
if (value !== 0 && !value) {
|
|
179
|
+
return "NULL";
|
|
180
|
+
}
|
|
181
|
+
if (value instanceof Date) {
|
|
182
|
+
return value.toISOString();
|
|
183
|
+
}
|
|
184
|
+
if (typeof value === "string") {
|
|
185
|
+
if (dbType === "POSTGRESQL") {
|
|
186
|
+
return "'" + value.replace(/'/g, "''") + "'";
|
|
187
|
+
} else {
|
|
188
|
+
return "'" + value.replace(/'/g, "\\'") + "'";
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (typeof value === "object") {
|
|
192
|
+
return "'" + JSON.stringify(value).replace(/'/g, "\\'") + "'";
|
|
193
|
+
}
|
|
194
|
+
if (typeof value === "boolean") {
|
|
195
|
+
return value ? "TRUE" : "FALSE";
|
|
196
|
+
}
|
|
197
|
+
if (typeof value === "number") {
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
|
|
27
204
|
declare global {
|
|
28
205
|
namespace Express {
|
|
29
206
|
interface Request {
|
|
@@ -39,34 +216,47 @@ export default (db, config) => {
|
|
|
39
216
|
req.config = {
|
|
40
217
|
...config,
|
|
41
218
|
views: config.views
|
|
42
|
-
.filter(
|
|
43
|
-
|
|
219
|
+
.filter(
|
|
220
|
+
(x) =>
|
|
221
|
+
!x.roles ||
|
|
222
|
+
(req.session.user &&
|
|
223
|
+
[...(x.roles || []), ...(x.readRoles || [])].includes(
|
|
224
|
+
req.session.user.role,
|
|
225
|
+
)),
|
|
44
226
|
)
|
|
45
|
-
.map(view => {
|
|
46
|
-
const readOnly =
|
|
227
|
+
.map((view) => {
|
|
228
|
+
const readOnly =
|
|
229
|
+
req.session.user &&
|
|
230
|
+
view.roles &&
|
|
231
|
+
!view.roles.includes(req.session.user.role);
|
|
47
232
|
if (readOnly) {
|
|
48
233
|
return {
|
|
49
234
|
...view,
|
|
50
235
|
canEdit: false,
|
|
51
236
|
canCreate: false,
|
|
52
237
|
canDelete: false,
|
|
53
|
-
clickToEdit: false
|
|
54
|
-
}
|
|
238
|
+
clickToEdit: false,
|
|
239
|
+
};
|
|
55
240
|
} else {
|
|
56
241
|
return view;
|
|
57
242
|
}
|
|
58
243
|
})
|
|
59
|
-
.map(view => {
|
|
244
|
+
.map((view) => {
|
|
60
245
|
if (view.type === "table") {
|
|
61
246
|
return {
|
|
62
247
|
...view,
|
|
63
|
-
columns: view.columns.filter(
|
|
64
|
-
|
|
248
|
+
columns: view.columns.filter(
|
|
249
|
+
(column) =>
|
|
250
|
+
!column.roles ||
|
|
251
|
+
(req.session.user &&
|
|
252
|
+
column.roles.includes(req.session.user.role)),
|
|
253
|
+
),
|
|
254
|
+
};
|
|
65
255
|
} else {
|
|
66
256
|
return view;
|
|
67
257
|
}
|
|
68
|
-
})
|
|
69
|
-
}
|
|
258
|
+
}),
|
|
259
|
+
};
|
|
70
260
|
next();
|
|
71
261
|
});
|
|
72
262
|
|
|
@@ -78,77 +268,10 @@ export default (db, config) => {
|
|
|
78
268
|
});
|
|
79
269
|
|
|
80
270
|
// ---------------------------------------------------------------------------
|
|
81
|
-
// SECURITY: server-side authorization
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// is shown, and sets canEdit/canCreate/canDelete flags on that served config.
|
|
85
|
-
// Nothing enforced those decisions server-side, so a session with the lowest
|
|
86
|
-
// role could POST directly to /updateRecord, /deleteRecord, /customQuery, …
|
|
87
|
-
// and act on any view — including read-only ones — as the DB user (root).
|
|
88
|
-
// This block re-checks every sensitive route against the view's declared roles.
|
|
271
|
+
// SECURITY: server-side authorization — see the helpers above (exported at
|
|
272
|
+
// module scope for testability): roleOf, canWriteView, viewFromRequest,
|
|
273
|
+
// MUTATING_VIEW_ROUTES, areSafeColumnNames, writableTables, looksLikeSqlBreakout.
|
|
89
274
|
// ---------------------------------------------------------------------------
|
|
90
|
-
const ROOT_ROLE = "root";
|
|
91
|
-
const roleOf = (req) => req.session && req.session.user && req.session.user.role;
|
|
92
|
-
// Write access = the view's `roles` (root always allowed).
|
|
93
|
-
const canWriteView = (view, role) =>
|
|
94
|
-
role === ROOT_ROLE || !view || !view.roles || view.roles.includes(role);
|
|
95
|
-
|
|
96
|
-
const viewFromRequest = (req) => {
|
|
97
|
-
const idx =
|
|
98
|
-
req.body && req.body.viewIndex !== undefined
|
|
99
|
-
? req.body.viewIndex
|
|
100
|
-
: req.query && req.query.viewIndex !== undefined
|
|
101
|
-
? req.query.viewIndex
|
|
102
|
-
: undefined;
|
|
103
|
-
if (idx === undefined || !req.config || !req.config.views) return undefined;
|
|
104
|
-
return req.config.views[idx];
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
// Routes that mutate data and reference their target view via viewIndex
|
|
108
|
-
// (body for POST bodies, query for /uploadFile). Import routes carry the
|
|
109
|
-
// viewIndex inside a multipart body and are guarded inline in their handlers.
|
|
110
|
-
const MUTATING_VIEW_ROUTES = new Set([
|
|
111
|
-
"/updateRecord",
|
|
112
|
-
"/createRecord",
|
|
113
|
-
"/deleteRecord",
|
|
114
|
-
"/deleteRecords",
|
|
115
|
-
"/deleteRecordFromPanel",
|
|
116
|
-
"/customAction",
|
|
117
|
-
"/uploadFile",
|
|
118
|
-
]);
|
|
119
|
-
// /duplicateRecord references its table via req.body.table (not a viewIndex),
|
|
120
|
-
// so it is guarded inline in its handler instead of by the generic middleware.
|
|
121
|
-
|
|
122
|
-
// Column names are interpolated into INSERT/UPDATE statements; only accept plain
|
|
123
|
-
// SQL identifiers so a crafted key cannot break out of the column list.
|
|
124
|
-
const areSafeColumnNames = (data) =>
|
|
125
|
-
!data ||
|
|
126
|
-
Object.keys(data).every((k) => /^[A-Za-z0-9_]+$/.test(k));
|
|
127
|
-
|
|
128
|
-
// Tables the current role is allowed to write, gathered from its writable views
|
|
129
|
-
// and their creatable panels — used to bound /duplicateRecord's target table.
|
|
130
|
-
const writableTables = (req) => {
|
|
131
|
-
const role = roleOf(req);
|
|
132
|
-
const tables = new Set<string>();
|
|
133
|
-
for (const view of req.config?.views || []) {
|
|
134
|
-
if (!canWriteView(view, role)) continue;
|
|
135
|
-
if (view.tableName) tables.add(view.tableName);
|
|
136
|
-
for (const panel of view.recordViewPanels || []) {
|
|
137
|
-
if (panel.tableName) tables.add(panel.tableName);
|
|
138
|
-
if (panel.createConfig?.tableName) tables.add(panel.createConfig.tableName);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return tables;
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
// Reject client-supplied SQL fragments (the `filters` array is concatenated
|
|
145
|
-
// raw into the WHERE clause). With multipleStatements off this already blocks
|
|
146
|
-
// stacked writes; the pattern also blocks UNION/comment/file exfil breakouts.
|
|
147
|
-
const looksLikeSqlBreakout = (frag) =>
|
|
148
|
-
typeof frag === "string" &&
|
|
149
|
-
/(;|--|\/\*|\bunion\b|\binto\s+(out|dump)file\b|\bsleep\s*\(|\bbenchmark\s*\(|\bload_file\s*\()/i.test(
|
|
150
|
-
frag
|
|
151
|
-
);
|
|
152
275
|
|
|
153
276
|
app.use((req, res, next) => {
|
|
154
277
|
const role = roleOf(req);
|
|
@@ -198,10 +321,17 @@ export default (db, config) => {
|
|
|
198
321
|
} else if (columns[i].name) {
|
|
199
322
|
queryString += columns[i].name
|
|
200
323
|
.split(".")
|
|
201
|
-
.map((x) => (db.dbType
|
|
324
|
+
.map((x) => quoteIdentifier(x, db.dbType))
|
|
202
325
|
.join(".");
|
|
203
326
|
}
|
|
204
|
-
|
|
327
|
+
// SECURITY: escape the user query per dialect — PostgreSQL does not
|
|
328
|
+
// treat backslashes as escapes inside standard strings, so a `\'`
|
|
329
|
+
// would terminate the literal early.
|
|
330
|
+
const searchEscaped =
|
|
331
|
+
db.dbType === "POSTGRESQL"
|
|
332
|
+
? params.query.replace(/'/g, "''")
|
|
333
|
+
: params.query.replace(/'/g, "\\'");
|
|
334
|
+
queryString += " LIKE '%" + searchEscaped + "%'";
|
|
205
335
|
isNewCondition = false;
|
|
206
336
|
}
|
|
207
337
|
}
|
|
@@ -210,16 +340,16 @@ export default (db, config) => {
|
|
|
210
340
|
const limit = params.allRecords ? 999999 : parseInt(params.limit);
|
|
211
341
|
const query =
|
|
212
342
|
params.customQuery ||
|
|
213
|
-
`SELECT
|
|
214
|
-
${params.selectExpression || "*"}
|
|
215
|
-
FROM ${params.table}
|
|
216
|
-
${params.join || ""}
|
|
217
|
-
${queryString}
|
|
218
|
-
${params.groupBy ? "GROUP BY " + params.groupBy : ""}
|
|
219
|
-
${params.having ? " HAVING " + params.having : ""}
|
|
343
|
+
`SELECT
|
|
344
|
+
${params.selectExpression || "*"}
|
|
345
|
+
FROM ${params.table}
|
|
346
|
+
${params.join || ""}
|
|
347
|
+
${queryString}
|
|
348
|
+
${params.groupBy ? "GROUP BY " + params.groupBy : ""}
|
|
349
|
+
${params.having ? " HAVING " + params.having : ""}
|
|
220
350
|
${params.orderBy || params.primaryId ? " ORDER BY " + (params.orderBy || `${params.table}.${params.primaryId}`) : ""}`;
|
|
221
351
|
const { err, rows } = await db.query(
|
|
222
|
-
`${query} LIMIT ${limit} OFFSET ${Math.max((params.page - 1) * limit, 0)}
|
|
352
|
+
`${query} LIMIT ${limit} OFFSET ${Math.max((params.page - 1) * limit, 0)}`,
|
|
223
353
|
);
|
|
224
354
|
if (err) {
|
|
225
355
|
console.error(err);
|
|
@@ -230,7 +360,7 @@ export default (db, config) => {
|
|
|
230
360
|
let count = undefined;
|
|
231
361
|
if (computeCount) {
|
|
232
362
|
const { err: countErr, rows: countRows } = await db.query(
|
|
233
|
-
`SELECT COUNT(*) AS count FROM (${query}) s
|
|
363
|
+
`SELECT COUNT(*) AS count FROM (${query}) s`,
|
|
234
364
|
);
|
|
235
365
|
if (countErr) {
|
|
236
366
|
const { rows: allRows } = await db.query(query);
|
|
@@ -256,7 +386,7 @@ export default (db, config) => {
|
|
|
256
386
|
Object.assign(params, {
|
|
257
387
|
page: parseInt(params.position) + parseInt(params.offset),
|
|
258
388
|
limit: 1,
|
|
259
|
-
})
|
|
389
|
+
}),
|
|
260
390
|
);
|
|
261
391
|
}
|
|
262
392
|
|
|
@@ -277,15 +407,15 @@ export default (db, config) => {
|
|
|
277
407
|
const pkg = JSON.parse(
|
|
278
408
|
fs.readFileSync(
|
|
279
409
|
path.join(__dirname, "../src/front/package.json"),
|
|
280
|
-
"utf-8"
|
|
281
|
-
)
|
|
410
|
+
"utf-8",
|
|
411
|
+
),
|
|
282
412
|
);
|
|
283
413
|
const external = Object.keys(pkg.dependencies || {});
|
|
284
414
|
const bundle = await rollup.rollup({
|
|
285
415
|
input: path.join(
|
|
286
416
|
__dirname,
|
|
287
417
|
"../../",
|
|
288
|
-
req.query.componentPath as string
|
|
418
|
+
req.query.componentPath as string,
|
|
289
419
|
),
|
|
290
420
|
plugins: [
|
|
291
421
|
resolve(),
|
|
@@ -303,6 +433,7 @@ export default (db, config) => {
|
|
|
303
433
|
res.send(output[0].code);
|
|
304
434
|
} catch (err) {
|
|
305
435
|
console.error(err);
|
|
436
|
+
res.sendStatus(500);
|
|
306
437
|
}
|
|
307
438
|
});
|
|
308
439
|
|
|
@@ -317,27 +448,6 @@ export default (db, config) => {
|
|
|
317
448
|
});
|
|
318
449
|
});
|
|
319
450
|
|
|
320
|
-
const sqlAnd = (conditions) => {
|
|
321
|
-
const existingConditions = [];
|
|
322
|
-
for (const condition of conditions) {
|
|
323
|
-
if (condition && condition.length > 0) {
|
|
324
|
-
existingConditions.push(condition);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
if (existingConditions.length <= 1) {
|
|
328
|
-
return existingConditions[0] || null;
|
|
329
|
-
} else {
|
|
330
|
-
let result = "";
|
|
331
|
-
for (let i = 0; i < existingConditions.length; i++) {
|
|
332
|
-
if (i > 0) {
|
|
333
|
-
result += " AND ";
|
|
334
|
-
}
|
|
335
|
-
result += "(" + existingConditions[i] + ")";
|
|
336
|
-
}
|
|
337
|
-
return result;
|
|
338
|
-
}
|
|
339
|
-
};
|
|
340
|
-
|
|
341
451
|
app.post("/foreignSelectRecords", async (req, res, next) => {
|
|
342
452
|
const view = req.config.views[req.body.viewIndex];
|
|
343
453
|
const column = [
|
|
@@ -360,7 +470,10 @@ export default (db, config) => {
|
|
|
360
470
|
? `${column.searchableName} LIKE '${column.wholeSearch ? "%" : ""}${queryEscaped}%'`
|
|
361
471
|
: null,
|
|
362
472
|
column.filtersFromUser
|
|
363
|
-
? column.filtersFromUser(
|
|
473
|
+
? column.filtersFromUser(
|
|
474
|
+
req.session.user.userId,
|
|
475
|
+
req.session.user.role,
|
|
476
|
+
)
|
|
364
477
|
: null,
|
|
365
478
|
]),
|
|
366
479
|
join: column.join,
|
|
@@ -388,7 +501,7 @@ export default (db, config) => {
|
|
|
388
501
|
value,
|
|
389
502
|
label,
|
|
390
503
|
};
|
|
391
|
-
})
|
|
504
|
+
}),
|
|
392
505
|
);
|
|
393
506
|
res.send(options);
|
|
394
507
|
});
|
|
@@ -415,7 +528,10 @@ export default (db, config) => {
|
|
|
415
528
|
subview?.filters,
|
|
416
529
|
...req.body.filters,
|
|
417
530
|
view.filtersFromUser
|
|
418
|
-
? view.filtersFromUser(
|
|
531
|
+
? view.filtersFromUser(
|
|
532
|
+
req.session.user.userId,
|
|
533
|
+
req.session.user.role,
|
|
534
|
+
)
|
|
419
535
|
: null,
|
|
420
536
|
]),
|
|
421
537
|
having: subview?.having,
|
|
@@ -434,7 +550,7 @@ export default (db, config) => {
|
|
|
434
550
|
columns: view.columns,
|
|
435
551
|
limit: view.limit || 10,
|
|
436
552
|
},
|
|
437
|
-
true
|
|
553
|
+
true,
|
|
438
554
|
);
|
|
439
555
|
if (error) {
|
|
440
556
|
return res.status(500).send(error);
|
|
@@ -457,86 +573,6 @@ export default (db, config) => {
|
|
|
457
573
|
|
|
458
574
|
const formattedValueToFunction = {};
|
|
459
575
|
|
|
460
|
-
async function getFormattedValue(column, record) {
|
|
461
|
-
let value;
|
|
462
|
-
try {
|
|
463
|
-
record.options = column.options;
|
|
464
|
-
if (column.formatValueForExport) {
|
|
465
|
-
value = await column.formatValueForExport(record);
|
|
466
|
-
} else if (column.formattedValue) {
|
|
467
|
-
value = evalExpression(column.formattedValue, record);
|
|
468
|
-
} else if (column.formatValue) {
|
|
469
|
-
value = await column.formatValue(record);
|
|
470
|
-
} else if (column.dataType == "tinyint") {
|
|
471
|
-
value = record[column.name] == 1 ? "Oui" : "Non";
|
|
472
|
-
} else {
|
|
473
|
-
value = record[column.name];
|
|
474
|
-
}
|
|
475
|
-
return value === null || typeof value == "undefined" ? "" : value;
|
|
476
|
-
} catch (err) {
|
|
477
|
-
return value || "";
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function evalExpression(expression, data) {
|
|
482
|
-
let varString = "";
|
|
483
|
-
let statement = "";
|
|
484
|
-
|
|
485
|
-
try {
|
|
486
|
-
for (let key in data) {
|
|
487
|
-
statement =
|
|
488
|
-
"var " +
|
|
489
|
-
key +
|
|
490
|
-
" = " +
|
|
491
|
-
(typeof data[key] === "string"
|
|
492
|
-
? "'" +
|
|
493
|
-
data[key]
|
|
494
|
-
.replace(/'/g, "\\'")
|
|
495
|
-
.replace(/\n/g, "")
|
|
496
|
-
.replace(/\r/g, "") +
|
|
497
|
-
"'"
|
|
498
|
-
: typeof data[key] === "object"
|
|
499
|
-
? JSON.stringify(data[key])
|
|
500
|
-
: data[key]) +
|
|
501
|
-
";";
|
|
502
|
-
varString += statement;
|
|
503
|
-
}
|
|
504
|
-
const result = eval(varString + expression);
|
|
505
|
-
return result;
|
|
506
|
-
} catch (e) {
|
|
507
|
-
console.log("Problème d'évaluation de l'expression", statement);
|
|
508
|
-
console.error(e);
|
|
509
|
-
return "Error";
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
function formatValueForInsertion(value: any, dbType: string) {
|
|
514
|
-
if (value !== 0 && !value) {
|
|
515
|
-
return "NULL";
|
|
516
|
-
}
|
|
517
|
-
if (value instanceof Date) {
|
|
518
|
-
return value.toISOString();
|
|
519
|
-
}
|
|
520
|
-
if (typeof value === "string") {
|
|
521
|
-
if (dbType === "POSTGRESQL") {
|
|
522
|
-
return "'" + value.replace(/'/g, "''") + "'";
|
|
523
|
-
} else {
|
|
524
|
-
return "'" + value.replace(/'/g, "\\'") + "'";
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
if (typeof value === "object") {
|
|
528
|
-
return "'" + JSON.stringify(value).replace(/'/g, "\\'") + "'";
|
|
529
|
-
}
|
|
530
|
-
if (typeof value === "boolean") {
|
|
531
|
-
return value ? "TRUE" : "FALSE";
|
|
532
|
-
}
|
|
533
|
-
if (typeof value === "number") {
|
|
534
|
-
return value;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
return value;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
576
|
app.post("/recordsExport", async function (req, res) {
|
|
541
577
|
const view = req.config.views[req.body.viewIndex];
|
|
542
578
|
const orderBy = req.body.orderBy || view.orderBy;
|
|
@@ -549,7 +585,19 @@ export default (db, config) => {
|
|
|
549
585
|
table: view.tableName,
|
|
550
586
|
page: 1,
|
|
551
587
|
allRecords: true,
|
|
552
|
-
filters: sqlAnd([
|
|
588
|
+
filters: sqlAnd([
|
|
589
|
+
view.filters,
|
|
590
|
+
subview?.filters,
|
|
591
|
+
req.body.filters,
|
|
592
|
+
// SECURITY: scope the export exactly like the list view, otherwise a
|
|
593
|
+
// user could download rows their filtersFromUser clause hides.
|
|
594
|
+
view.filtersFromUser
|
|
595
|
+
? view.filtersFromUser(
|
|
596
|
+
req.session.user.userId,
|
|
597
|
+
req.session.user.role,
|
|
598
|
+
)
|
|
599
|
+
: null,
|
|
600
|
+
]),
|
|
553
601
|
having: subview?.having,
|
|
554
602
|
customQuery: view.customQuery,
|
|
555
603
|
join: view.join,
|
|
@@ -601,11 +649,16 @@ export default (db, config) => {
|
|
|
601
649
|
}
|
|
602
650
|
// Get viewIndex in fields
|
|
603
651
|
const view = req.config.views[fields.viewIndex[0]];
|
|
652
|
+
// SECURITY: the generic write-guard cannot see a viewIndex buried in a
|
|
653
|
+
// multipart body, so imports enforce the view's write roles here.
|
|
654
|
+
if (!view || !canWriteView(view, roleOf(req))) {
|
|
655
|
+
return res.sendStatus(403);
|
|
656
|
+
}
|
|
604
657
|
if (typeof view.parseImportFile !== "function") {
|
|
605
658
|
return res
|
|
606
659
|
.status(500)
|
|
607
660
|
.send(
|
|
608
|
-
'"parseImportFile" doit être défini dans la configuration de la vue.'
|
|
661
|
+
'"parseImportFile" doit être défini dans la configuration de la vue.',
|
|
609
662
|
);
|
|
610
663
|
}
|
|
611
664
|
|
|
@@ -628,7 +681,7 @@ export default (db, config) => {
|
|
|
628
681
|
|
|
629
682
|
let queryString;
|
|
630
683
|
if (db.dbType === "POSTGRESQL") {
|
|
631
|
-
queryString = `INSERT INTO ?? (${columns.map((x) =>
|
|
684
|
+
queryString = `INSERT INTO ?? (${columns.map((x) => quoteIdentifier(x, db.dbType)).join(", ")}) VALUES`;
|
|
632
685
|
|
|
633
686
|
for (let record of records) {
|
|
634
687
|
const valuesList = columns
|
|
@@ -641,12 +694,12 @@ export default (db, config) => {
|
|
|
641
694
|
` ON CONFLICT (${view.importFileUniqueField}) DO UPDATE SET `;
|
|
642
695
|
|
|
643
696
|
for (let column of columns) {
|
|
644
|
-
queryString +=
|
|
697
|
+
queryString += `${quoteIdentifier(column, db.dbType)} = EXCLUDED.${quoteIdentifier(column, db.dbType)},`;
|
|
645
698
|
}
|
|
646
699
|
} else {
|
|
647
700
|
queryString =
|
|
648
701
|
"INSERT INTO ?? (" +
|
|
649
|
-
columns.map((x) =>
|
|
702
|
+
columns.map((x) => quoteIdentifier(x, db.dbType)).join(", ") +
|
|
650
703
|
") VALUES";
|
|
651
704
|
for (let record of records) {
|
|
652
705
|
queryString +=
|
|
@@ -658,7 +711,7 @@ export default (db, config) => {
|
|
|
658
711
|
}
|
|
659
712
|
queryString = queryString.slice(0, -1) + " ON DUPLICATE KEY UPDATE";
|
|
660
713
|
for (let column of columns) {
|
|
661
|
-
queryString +=
|
|
714
|
+
queryString += ` ${quoteIdentifier(column, db.dbType)} = VALUES(${quoteIdentifier(column, db.dbType)}),`;
|
|
662
715
|
}
|
|
663
716
|
}
|
|
664
717
|
queryString = queryString.slice(0, -1); // Remove the trailing comma
|
|
@@ -692,25 +745,37 @@ export default (db, config) => {
|
|
|
692
745
|
}
|
|
693
746
|
// Get viewIndex and panelIndex in fields
|
|
694
747
|
const view = req.config.views[fields.viewIndex[0]];
|
|
695
|
-
const panel = view
|
|
748
|
+
const panel = view
|
|
749
|
+
? view.recordViewPanels[fields.panelIndex[0]]
|
|
750
|
+
: undefined;
|
|
751
|
+
// SECURITY: same write-guard as /recordsImport, plus the panel's own
|
|
752
|
+
// roles when it declares any.
|
|
753
|
+
if (
|
|
754
|
+
!view ||
|
|
755
|
+
!canWriteView(view, roleOf(req)) ||
|
|
756
|
+
!panel ||
|
|
757
|
+
!canWriteView(panel, roleOf(req))
|
|
758
|
+
) {
|
|
759
|
+
return res.sendStatus(403);
|
|
760
|
+
}
|
|
696
761
|
if (typeof panel.parseImportFile !== "function") {
|
|
697
762
|
return res
|
|
698
763
|
.status(500)
|
|
699
764
|
.send(
|
|
700
|
-
'"parseImportFile" doit être défini dans la configuration du panneau.'
|
|
765
|
+
'"parseImportFile" doit être défini dans la configuration du panneau.',
|
|
701
766
|
);
|
|
702
767
|
}
|
|
703
768
|
if (!Boolean(panel.createConfig)) {
|
|
704
769
|
return res
|
|
705
770
|
.status(500)
|
|
706
771
|
.send(
|
|
707
|
-
'"createConfig" doit être défini dans la configuration du panneau.'
|
|
772
|
+
'"createConfig" doit être défini dans la configuration du panneau.',
|
|
708
773
|
);
|
|
709
774
|
}
|
|
710
775
|
|
|
711
776
|
const records = await panel.parseImportFile(
|
|
712
777
|
files.data[0],
|
|
713
|
-
fields.recordId[0]
|
|
778
|
+
fields.recordId[0],
|
|
714
779
|
);
|
|
715
780
|
const columns = [];
|
|
716
781
|
if (records.length === 0) {
|
|
@@ -729,10 +794,10 @@ export default (db, config) => {
|
|
|
729
794
|
}
|
|
730
795
|
|
|
731
796
|
let queryString =
|
|
732
|
-
"INSERT IGNORE INTO ?? (
|
|
733
|
-
panel.createConfig.recordIdKey +
|
|
734
|
-
"
|
|
735
|
-
columns.map((x) =>
|
|
797
|
+
"INSERT IGNORE INTO ?? (" +
|
|
798
|
+
quoteIdentifier(panel.createConfig.recordIdKey, db.dbType) +
|
|
799
|
+
", " +
|
|
800
|
+
columns.map((x) => quoteIdentifier(x, db.dbType)).join(", ") +
|
|
736
801
|
") VALUES";
|
|
737
802
|
for (let record of records) {
|
|
738
803
|
queryString +=
|
|
@@ -746,7 +811,7 @@ export default (db, config) => {
|
|
|
746
811
|
}
|
|
747
812
|
queryString = queryString.slice(0, -1) + " ON DUPLICATE KEY UPDATE";
|
|
748
813
|
for (let column of columns) {
|
|
749
|
-
queryString +=
|
|
814
|
+
queryString += ` ${quoteIdentifier(column, db.dbType)} = VALUES(${quoteIdentifier(column, db.dbType)}),`;
|
|
750
815
|
}
|
|
751
816
|
const { err, rows: r } = await db.query(queryString.slice(0, -1), [
|
|
752
817
|
panel.tableName,
|
|
@@ -775,15 +840,15 @@ export default (db, config) => {
|
|
|
775
840
|
const view = req.config.views[req.query.viewIndex as string];
|
|
776
841
|
const { err, rows } = await db.query(
|
|
777
842
|
"SELECT " +
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
843
|
+
(view.selectExpression || "*") +
|
|
844
|
+
" FROM ?? " +
|
|
845
|
+
(view.join || "") +
|
|
846
|
+
" WHERE ?? = ?",
|
|
782
847
|
[
|
|
783
848
|
view.tableName,
|
|
784
849
|
view.tableName + "." + view.primaryId,
|
|
785
850
|
req.query.recordId,
|
|
786
|
-
]
|
|
851
|
+
],
|
|
787
852
|
);
|
|
788
853
|
if (err) {
|
|
789
854
|
return res.status(500).send(err);
|
|
@@ -820,10 +885,10 @@ export default (db, config) => {
|
|
|
820
885
|
return res.sendStatus(200);
|
|
821
886
|
}
|
|
822
887
|
const queryExpression = Object.keys(req.body.data)
|
|
823
|
-
.map((x) => `${x} = ?`)
|
|
888
|
+
.map((x) => `${quoteIdentifier(x, db.dbType)} = ?`)
|
|
824
889
|
.join(", ");
|
|
825
890
|
const queryParameters = Object.keys(req.body.data).map(
|
|
826
|
-
(x) => req.body.data[x]
|
|
891
|
+
(x) => req.body.data[x],
|
|
827
892
|
);
|
|
828
893
|
const { err } = await db.query(
|
|
829
894
|
`UPDATE ?? SET ${queryExpression} WHERE ?? = ?`,
|
|
@@ -832,7 +897,7 @@ export default (db, config) => {
|
|
|
832
897
|
...queryParameters,
|
|
833
898
|
view.primaryId,
|
|
834
899
|
req.body.primaryValue,
|
|
835
|
-
]
|
|
900
|
+
],
|
|
836
901
|
);
|
|
837
902
|
if (err) {
|
|
838
903
|
return res.status(500).send(err);
|
|
@@ -844,7 +909,7 @@ export default (db, config) => {
|
|
|
844
909
|
data: req.body.data,
|
|
845
910
|
previousData: req.body.previousData,
|
|
846
911
|
});
|
|
847
|
-
} catch (e) {
|
|
912
|
+
} catch (e) {}
|
|
848
913
|
}
|
|
849
914
|
if (view.editCallbackFunction) {
|
|
850
915
|
await view.editCallbackFunction({
|
|
@@ -862,14 +927,17 @@ export default (db, config) => {
|
|
|
862
927
|
try {
|
|
863
928
|
const { rows } = await db.query(
|
|
864
929
|
"SELECT " +
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
[view.tableName, view.tableName + "." + view.primaryId, recordId]
|
|
930
|
+
(view.selectExpression || "*") +
|
|
931
|
+
" FROM ?? " +
|
|
932
|
+
(view.join || "") +
|
|
933
|
+
" WHERE ?? = ?",
|
|
934
|
+
[view.tableName, view.tableName + "." + view.primaryId, recordId],
|
|
870
935
|
);
|
|
871
936
|
const record = rows[0];
|
|
872
|
-
const actionResult = await action.action(
|
|
937
|
+
const actionResult = await action.action(
|
|
938
|
+
record,
|
|
939
|
+
req.session.user.userId,
|
|
940
|
+
);
|
|
873
941
|
if (actionResult) {
|
|
874
942
|
return res.send({
|
|
875
943
|
success: true,
|
|
@@ -894,15 +962,15 @@ export default (db, config) => {
|
|
|
894
962
|
const view = req.config.views[req.body.viewIndex];
|
|
895
963
|
const { rows, err: err2 } = await db.query(
|
|
896
964
|
"SELECT " +
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
965
|
+
(view.selectExpression || "*") +
|
|
966
|
+
" FROM ?? " +
|
|
967
|
+
(view.join || "") +
|
|
968
|
+
" WHERE ?? = ?",
|
|
901
969
|
[
|
|
902
970
|
view.tableName,
|
|
903
971
|
view.tableName + "." + view.primaryId,
|
|
904
972
|
req.body.primaryValue,
|
|
905
|
-
]
|
|
973
|
+
],
|
|
906
974
|
);
|
|
907
975
|
if (err2) {
|
|
908
976
|
return res.status(500).send(err2);
|
|
@@ -924,7 +992,7 @@ export default (db, config) => {
|
|
|
924
992
|
await axios.post(view.deleteCallback, {
|
|
925
993
|
id: req.body.primaryValue,
|
|
926
994
|
});
|
|
927
|
-
} catch (e) {
|
|
995
|
+
} catch (e) {}
|
|
928
996
|
}
|
|
929
997
|
if (view.deleteCallbackFunction) {
|
|
930
998
|
await view.deleteCallbackFunction({
|
|
@@ -938,11 +1006,11 @@ export default (db, config) => {
|
|
|
938
1006
|
app.post("/deleteRecordFromPanel", async function (req, res) {
|
|
939
1007
|
const view = req.config.views[req.body.viewIndex];
|
|
940
1008
|
const panel = view.recordViewPanels.find(
|
|
941
|
-
(x) => x.__id__ === req.body.panelId
|
|
1009
|
+
(x) => x.__id__ === req.body.panelId,
|
|
942
1010
|
);
|
|
943
1011
|
const { rows, err: err2 } = await db.query(
|
|
944
1012
|
"SELECT * FROM ?? WHERE ?? = ?",
|
|
945
|
-
[panel.tableName, panel.primaryId, req.body.primaryValue]
|
|
1013
|
+
[panel.tableName, panel.primaryId, req.body.primaryValue],
|
|
946
1014
|
);
|
|
947
1015
|
if (err2) {
|
|
948
1016
|
return res.status(500).send(err2);
|
|
@@ -961,7 +1029,7 @@ export default (db, config) => {
|
|
|
961
1029
|
await axios.post(panel.deleteCallback, {
|
|
962
1030
|
id: req.body.primaryValue,
|
|
963
1031
|
});
|
|
964
|
-
} catch (e) {
|
|
1032
|
+
} catch (e) {}
|
|
965
1033
|
}
|
|
966
1034
|
if (panel.deleteCallbackFunction) {
|
|
967
1035
|
await panel.deleteCallbackFunction({
|
|
@@ -974,9 +1042,12 @@ export default (db, config) => {
|
|
|
974
1042
|
|
|
975
1043
|
app.post("/deleteRecords", async function (req, res, next) {
|
|
976
1044
|
const view = req.config.views[req.body.viewIndex];
|
|
1045
|
+
// SECURITY: bind the primary values as parameters instead of interpolating
|
|
1046
|
+
// them into the IN() list (quote-bearing values could break out).
|
|
1047
|
+
const placeholders = req.body.primaryValues.map(() => "?").join(", ");
|
|
977
1048
|
const { err } = await db.query(
|
|
978
|
-
`DELETE FROM ?? WHERE ?? IN(${
|
|
979
|
-
[view.tableName, view.primaryId]
|
|
1049
|
+
`DELETE FROM ?? WHERE ?? IN(${placeholders})`,
|
|
1050
|
+
[view.tableName, view.primaryId, ...req.body.primaryValues],
|
|
980
1051
|
);
|
|
981
1052
|
if (err) {
|
|
982
1053
|
return res.status(500).send(err);
|
|
@@ -1044,7 +1115,7 @@ export default (db, config) => {
|
|
|
1044
1115
|
}
|
|
1045
1116
|
const { err: err2, rows: r } = await db.query(
|
|
1046
1117
|
"INSERT IGNORE INTO ?? SET ?",
|
|
1047
|
-
[req.body.table, data]
|
|
1118
|
+
[req.body.table, data],
|
|
1048
1119
|
);
|
|
1049
1120
|
if (err2) {
|
|
1050
1121
|
return res.status(500).send(err);
|
|
@@ -1074,9 +1145,11 @@ export default (db, config) => {
|
|
|
1074
1145
|
|
|
1075
1146
|
app.post("/createRecord", async function (req, res, next) {
|
|
1076
1147
|
const view = req.config.views[req.body.viewIndex];
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1148
|
+
// panelIndex arrives as a string from the front-end but a numeric caller
|
|
1149
|
+
// may send 0 — only null/undefined mean "no panel".
|
|
1150
|
+
const hasPanel =
|
|
1151
|
+
req.body.panelIndex !== undefined && req.body.panelIndex !== null;
|
|
1152
|
+
const panel = hasPanel ? view.recordViewPanels[req.body.panelIndex] : null;
|
|
1080
1153
|
try {
|
|
1081
1154
|
for (let key in req.body.data) {
|
|
1082
1155
|
if (
|
|
@@ -1090,7 +1163,7 @@ export default (db, config) => {
|
|
|
1090
1163
|
req.body.data[panel.createConfig.recordIdKey] = req.body.recordId;
|
|
1091
1164
|
}
|
|
1092
1165
|
const primaryColumn = (view || panel)?.columns?.find(
|
|
1093
|
-
(x) => x.name === (view || panel).primaryId
|
|
1166
|
+
(x) => x.name === (view || panel).primaryId,
|
|
1094
1167
|
);
|
|
1095
1168
|
if (primaryColumn?.dataType == "uuid") {
|
|
1096
1169
|
req.body.data[primaryColumn.name] = uuidv4();
|
|
@@ -1108,16 +1181,18 @@ export default (db, config) => {
|
|
|
1108
1181
|
if (!areSafeColumnNames(createPayload)) {
|
|
1109
1182
|
return res.status(400).send({ error: "Invalid column name" });
|
|
1110
1183
|
}
|
|
1111
|
-
const queryExpression = Object.keys(createPayload)
|
|
1184
|
+
const queryExpression = Object.keys(createPayload)
|
|
1185
|
+
.map((x) => quoteIdentifier(x, db.dbType))
|
|
1186
|
+
.join(", ");
|
|
1112
1187
|
const queryPlaceholders = Object.keys(createPayload)
|
|
1113
1188
|
.map((x) => "?")
|
|
1114
1189
|
.join(", ");
|
|
1115
1190
|
const queryParameters = Object.keys(createPayload).map(
|
|
1116
|
-
(x) => createPayload[x]
|
|
1191
|
+
(x) => createPayload[x],
|
|
1117
1192
|
);
|
|
1118
1193
|
const { err, rows: r } = await db.query(
|
|
1119
1194
|
`INSERT INTO ?? (${queryExpression}) VALUES(${queryPlaceholders})`,
|
|
1120
|
-
[panel ? panel.tableName : view.tableName, ...queryParameters]
|
|
1195
|
+
[panel ? panel.tableName : view.tableName, ...queryParameters],
|
|
1121
1196
|
);
|
|
1122
1197
|
if (err) {
|
|
1123
1198
|
console.error(err);
|
|
@@ -1135,15 +1210,22 @@ export default (db, config) => {
|
|
|
1135
1210
|
id: r.insertId,
|
|
1136
1211
|
data: req.body.data,
|
|
1137
1212
|
});
|
|
1138
|
-
} catch (e) {
|
|
1213
|
+
} catch (e) {}
|
|
1139
1214
|
}
|
|
1140
1215
|
if (panel ? panel.createCallbackFunction : view.createCallbackFunction) {
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
)
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1216
|
+
// The insert is already committed: a failing callback must not turn
|
|
1217
|
+
// into a 500 that invites a client retry (which would duplicate the
|
|
1218
|
+
// row). Log and answer 200.
|
|
1219
|
+
try {
|
|
1220
|
+
await (
|
|
1221
|
+
panel ? panel.createCallbackFunction : view.createCallbackFunction
|
|
1222
|
+
)({
|
|
1223
|
+
id: r.insertId,
|
|
1224
|
+
data: req.body.data,
|
|
1225
|
+
});
|
|
1226
|
+
} catch (callbackErr) {
|
|
1227
|
+
console.error(callbackErr);
|
|
1228
|
+
}
|
|
1147
1229
|
}
|
|
1148
1230
|
res.send({
|
|
1149
1231
|
id: r.insertId,
|
|
@@ -1157,27 +1239,6 @@ export default (db, config) => {
|
|
|
1157
1239
|
}
|
|
1158
1240
|
});
|
|
1159
1241
|
|
|
1160
|
-
// Collect every SQL template declared anywhere in the (role-filtered) config:
|
|
1161
|
-
// panel `query`, `autocompleteListQuery`, etc. customQuery may only run a
|
|
1162
|
-
// template that already exists in the config the caller is allowed to see —
|
|
1163
|
-
// so it can never be turned into an arbitrary read/write primitive.
|
|
1164
|
-
function collectAllowedQueries(node, acc = new Set<string>()) {
|
|
1165
|
-
if (!node || typeof node !== "object") return acc;
|
|
1166
|
-
if (Array.isArray(node)) {
|
|
1167
|
-
for (const item of node) collectAllowedQueries(item, acc);
|
|
1168
|
-
return acc;
|
|
1169
|
-
}
|
|
1170
|
-
for (const key of Object.keys(node)) {
|
|
1171
|
-
const value = node[key];
|
|
1172
|
-
if (typeof value === "string" && (key === "query" || /Query$/.test(key))) {
|
|
1173
|
-
acc.add(value.trim());
|
|
1174
|
-
} else if (value && typeof value === "object") {
|
|
1175
|
-
collectAllowedQueries(value, acc);
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
return acc;
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
1242
|
// Attack-surface reduction: only expose /customQuery at all when the config
|
|
1182
1243
|
// actually declares SQL templates (panels, autocomplete lists, …). If nothing
|
|
1183
1244
|
// uses it, the route is replaced by an explicit "disabled" stub so the raw-SQL
|
|
@@ -1188,7 +1249,8 @@ export default (db, config) => {
|
|
|
1188
1249
|
app.get("/customQuery", (req, res) => res.sendStatus(404));
|
|
1189
1250
|
} else {
|
|
1190
1251
|
app.get("/customQuery", async function (req, res, next) {
|
|
1191
|
-
const template =
|
|
1252
|
+
const template =
|
|
1253
|
+
typeof req.query.query === "string" ? req.query.query : "";
|
|
1192
1254
|
// SECURITY: only run whitelisted, read-only templates, and bind the record id
|
|
1193
1255
|
// as a parameter instead of substituting it into the SQL string.
|
|
1194
1256
|
const allowed = collectAllowedQueries(req.config.views);
|
|
@@ -1196,7 +1258,9 @@ export default (db, config) => {
|
|
|
1196
1258
|
return res.status(403).send({ error: "Query not allowed" });
|
|
1197
1259
|
}
|
|
1198
1260
|
if (!/^\s*\(?\s*(select|with)\b/i.test(template) || /;/.test(template)) {
|
|
1199
|
-
return res
|
|
1261
|
+
return res
|
|
1262
|
+
.status(400)
|
|
1263
|
+
.send({ error: "Only read-only queries are allowed" });
|
|
1200
1264
|
}
|
|
1201
1265
|
const placeholderCount = (template.match(/\?/g) || []).length;
|
|
1202
1266
|
const params = new Array(placeholderCount).fill(req.query.primaryValue);
|
|
@@ -1207,7 +1271,7 @@ export default (db, config) => {
|
|
|
1207
1271
|
if (req.query.viewIndex && req.query.panelIndex) {
|
|
1208
1272
|
const panel =
|
|
1209
1273
|
req.config.views[req.query.viewIndex as string].recordViewPanels[
|
|
1210
|
-
|
|
1274
|
+
req.query.panelIndex as string
|
|
1211
1275
|
];
|
|
1212
1276
|
if (panel.formatValue) {
|
|
1213
1277
|
for (const row of rows) {
|
|
@@ -1224,7 +1288,7 @@ export default (db, config) => {
|
|
|
1224
1288
|
function uploadFile(column, fileParamName, req, res) {
|
|
1225
1289
|
if (column.s3Config || column.gcpConfig) {
|
|
1226
1290
|
const form = new multiparty.Form();
|
|
1227
|
-
form.parse(req, function (err, fields, files) {
|
|
1291
|
+
form.parse(req, async function (err, fields, files) {
|
|
1228
1292
|
if (err) {
|
|
1229
1293
|
return res.status(500).send(err);
|
|
1230
1294
|
}
|
|
@@ -1235,12 +1299,20 @@ export default (db, config) => {
|
|
|
1235
1299
|
const extension = originalFilename.match(/(?:\.([^.]+))?$/)[1];
|
|
1236
1300
|
let aspectRatio = null;
|
|
1237
1301
|
if (["png", "jpeg", "jpg", "gif"].includes(extension)) {
|
|
1238
|
-
|
|
1239
|
-
|
|
1302
|
+
try {
|
|
1303
|
+
const dimensions = await getImageDimensions(
|
|
1304
|
+
files[fileParamName][0].path,
|
|
1305
|
+
);
|
|
1306
|
+
if (dimensions) {
|
|
1307
|
+
aspectRatio = dimensions.height / dimensions.width;
|
|
1308
|
+
}
|
|
1309
|
+
} catch {
|
|
1310
|
+
// malformed image data: leave aspectRatio null rather than crash
|
|
1311
|
+
}
|
|
1240
1312
|
}
|
|
1241
1313
|
const originalFilenameWithoutExtension = originalFilename.replace(
|
|
1242
1314
|
"." + extension,
|
|
1243
|
-
""
|
|
1315
|
+
"",
|
|
1244
1316
|
);
|
|
1245
1317
|
|
|
1246
1318
|
if (column.gcpConfig) {
|
|
@@ -1302,7 +1374,7 @@ export default (db, config) => {
|
|
|
1302
1374
|
Key: path,
|
|
1303
1375
|
ContentType: contentType,
|
|
1304
1376
|
}),
|
|
1305
|
-
{ expiresIn: 150 }
|
|
1377
|
+
{ expiresIn: 150 },
|
|
1306
1378
|
)
|
|
1307
1379
|
.then((signedUrl) =>
|
|
1308
1380
|
axios.put(
|
|
@@ -1314,8 +1386,8 @@ export default (db, config) => {
|
|
|
1314
1386
|
headers: {
|
|
1315
1387
|
"Content-Type": contentType,
|
|
1316
1388
|
},
|
|
1317
|
-
}
|
|
1318
|
-
)
|
|
1389
|
+
},
|
|
1390
|
+
),
|
|
1319
1391
|
)
|
|
1320
1392
|
.then(() => {
|
|
1321
1393
|
res.status(200).send({
|
|
@@ -1337,7 +1409,7 @@ export default (db, config) => {
|
|
|
1337
1409
|
ContentType: contentType,
|
|
1338
1410
|
ACL: "public-read",
|
|
1339
1411
|
Key: path,
|
|
1340
|
-
})
|
|
1412
|
+
}),
|
|
1341
1413
|
)
|
|
1342
1414
|
.then(() => {
|
|
1343
1415
|
res.send({
|
|
@@ -1377,9 +1449,9 @@ export default (db, config) => {
|
|
|
1377
1449
|
await sftp.fastPut(
|
|
1378
1450
|
files[fileParamName][0].path,
|
|
1379
1451
|
column.publicPath +
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1452
|
+
(column.subdirectory ? "/" + column.subdirectory : "") +
|
|
1453
|
+
"/" +
|
|
1454
|
+
filename,
|
|
1383
1455
|
);
|
|
1384
1456
|
await sftp.end();
|
|
1385
1457
|
res.send({
|
|
@@ -1409,11 +1481,18 @@ export default (db, config) => {
|
|
|
1409
1481
|
app.post(
|
|
1410
1482
|
"/uploadFileFromBetterRichTextInput",
|
|
1411
1483
|
async function (req, res, next) {
|
|
1484
|
+
// SECURITY: the upload target comes entirely from the client header and
|
|
1485
|
+
// this route carries no viewIndex, so gate it on the caller being able
|
|
1486
|
+
// to write at least one view (read-only roles are rejected outright).
|
|
1487
|
+
const role = roleOf(req);
|
|
1488
|
+
if (!req.config.views.some((view) => canWriteView(view, role))) {
|
|
1489
|
+
return res.sendStatus(403);
|
|
1490
|
+
}
|
|
1412
1491
|
const fileUploadProperties = JSON.parse(
|
|
1413
|
-
req.headers["file-upload-properties"] as string
|
|
1492
|
+
req.headers["file-upload-properties"] as string,
|
|
1414
1493
|
);
|
|
1415
1494
|
uploadFile(fileUploadProperties, "upload", req, res);
|
|
1416
|
-
}
|
|
1495
|
+
},
|
|
1417
1496
|
);
|
|
1418
1497
|
|
|
1419
1498
|
app.post("/uploadFile", async function (req, res, next) {
|
|
@@ -1438,12 +1517,12 @@ export default (db, config) => {
|
|
|
1438
1517
|
where: `denominationunitelegale LIKE '${processedQuery}*' OR siret LIKE '${processedQuery}*'`,
|
|
1439
1518
|
limit: 100,
|
|
1440
1519
|
},
|
|
1441
|
-
}
|
|
1520
|
+
},
|
|
1442
1521
|
);
|
|
1443
1522
|
const results = response.data.results
|
|
1444
1523
|
.filter(
|
|
1445
1524
|
(etablissement) =>
|
|
1446
|
-
etablissement.etatadministratifetablissement === "Actif"
|
|
1525
|
+
etablissement.etatadministratifetablissement === "Actif",
|
|
1447
1526
|
)
|
|
1448
1527
|
.map((etablissement) => ({
|
|
1449
1528
|
id: Math.random().toString().slice(2),
|
|
@@ -1465,10 +1544,31 @@ export default (db, config) => {
|
|
|
1465
1544
|
return app;
|
|
1466
1545
|
};
|
|
1467
1546
|
|
|
1468
|
-
|
|
1547
|
+
// Collect every SQL template declared anywhere in the (role-filtered) config:
|
|
1548
|
+
// panel `query`, `autocompleteListQuery`, etc. customQuery may only run a
|
|
1549
|
+
// template that already exists in the config the caller is allowed to see —
|
|
1550
|
+
// so it can never be turned into an arbitrary read/write primitive.
|
|
1551
|
+
export function collectAllowedQueries(node, acc = new Set<string>()) {
|
|
1552
|
+
if (!node || typeof node !== "object") return acc;
|
|
1553
|
+
if (Array.isArray(node)) {
|
|
1554
|
+
for (const item of node) collectAllowedQueries(item, acc);
|
|
1555
|
+
return acc;
|
|
1556
|
+
}
|
|
1557
|
+
for (const key of Object.keys(node)) {
|
|
1558
|
+
const value = node[key];
|
|
1559
|
+
if (typeof value === "string" && (key === "query" || /Query$/.test(key))) {
|
|
1560
|
+
acc.add(value.trim());
|
|
1561
|
+
} else if (value && typeof value === "object") {
|
|
1562
|
+
collectAllowedQueries(value, acc);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
return acc;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
export async function enrichConfig(config, db) {
|
|
1469
1569
|
const { rows: info, err } = await db.query(
|
|
1470
1570
|
"SELECT * FROM information_schema.columns WHERE table_schema = ?",
|
|
1471
|
-
[config.mysql?.database || config.postgresql?.database]
|
|
1571
|
+
[config.mysql?.database || config.postgresql?.database],
|
|
1472
1572
|
);
|
|
1473
1573
|
if (err) {
|
|
1474
1574
|
console.error(err);
|
|
@@ -1512,12 +1612,12 @@ async function enrichConfig(config, db) {
|
|
|
1512
1612
|
config.enriched = true;
|
|
1513
1613
|
}
|
|
1514
1614
|
|
|
1515
|
-
function enrichColumn(column, tableName, info) {
|
|
1615
|
+
export function enrichColumn(column, tableName, info) {
|
|
1516
1616
|
if (!column.__id__) {
|
|
1517
1617
|
column.__id__ = generateUniqueIdForObject(column);
|
|
1518
1618
|
}
|
|
1519
1619
|
const infoEl = info.find(
|
|
1520
|
-
(x) => x.TABLE_NAME == tableName && x.COLUMN_NAME == column.name
|
|
1620
|
+
(x) => x.TABLE_NAME == tableName && x.COLUMN_NAME == column.name,
|
|
1521
1621
|
);
|
|
1522
1622
|
if (
|
|
1523
1623
|
infoEl?.IS_NULLABLE == "NO" &&
|
|
@@ -1534,7 +1634,7 @@ function enrichColumn(column, tableName, info) {
|
|
|
1534
1634
|
column.options = JSON.parse(
|
|
1535
1635
|
infoEl.COLUMN_TYPE.replace("enum(", "[")
|
|
1536
1636
|
.replace(")", "]")
|
|
1537
|
-
.replace(/'/g, '"')
|
|
1637
|
+
.replace(/'/g, '"'),
|
|
1538
1638
|
);
|
|
1539
1639
|
}
|
|
1540
1640
|
}
|