@galadrim/galadmin 0.1.236 → 0.2.7
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 +34 -3
- package/README.md +65 -0
- package/dist/api.d.ts +16 -0
- package/dist/api.js +240 -189
- package/dist/api.js.map +1 -1
- package/dist/index.js +25 -3
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/config.d.ts +6 -1
- package/dist/utils/dbManager.d.ts +5 -0
- package/dist/utils/dbManager.js +146 -11
- 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 +284 -217
- 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 +26 -3
- package/src/types/config.ts +13 -1
- package/src/utils/dbManager.ts +243 -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 +412 -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
|
|
|
@@ -20,12 +20,187 @@ import { v4 as uuidv4 } from "uuid";
|
|
|
20
20
|
|
|
21
21
|
import { quoteIdentifier } from "./utils/dbManager";
|
|
22
22
|
|
|
23
|
-
function generateUniqueIdForObject(obj) {
|
|
23
|
+
export function generateUniqueIdForObject(obj) {
|
|
24
24
|
const str = JSON.stringify(obj);
|
|
25
25
|
const hash = crypto.createHash("sha256").update(str).digest("hex");
|
|
26
26
|
return hash;
|
|
27
27
|
}
|
|
28
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
|
+
|
|
29
204
|
declare global {
|
|
30
205
|
namespace Express {
|
|
31
206
|
interface Request {
|
|
@@ -93,78 +268,10 @@ export default (db, config) => {
|
|
|
93
268
|
});
|
|
94
269
|
|
|
95
270
|
// ---------------------------------------------------------------------------
|
|
96
|
-
// SECURITY: server-side authorization
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
// is shown, and sets canEdit/canCreate/canDelete flags on that served config.
|
|
100
|
-
// Nothing enforced those decisions server-side, so a session with the lowest
|
|
101
|
-
// role could POST directly to /updateRecord, /deleteRecord, /customQuery, …
|
|
102
|
-
// and act on any view — including read-only ones — as the DB user (root).
|
|
103
|
-
// 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.
|
|
104
274
|
// ---------------------------------------------------------------------------
|
|
105
|
-
const ROOT_ROLE = "root";
|
|
106
|
-
const roleOf = (req) =>
|
|
107
|
-
req.session && req.session.user && req.session.user.role;
|
|
108
|
-
// Write access = the view's `roles` (root always allowed).
|
|
109
|
-
const canWriteView = (view, role) =>
|
|
110
|
-
role === ROOT_ROLE || !view || !view.roles || view.roles.includes(role);
|
|
111
|
-
|
|
112
|
-
const viewFromRequest = (req) => {
|
|
113
|
-
const idx =
|
|
114
|
-
req.body && req.body.viewIndex !== undefined
|
|
115
|
-
? req.body.viewIndex
|
|
116
|
-
: req.query && req.query.viewIndex !== undefined
|
|
117
|
-
? req.query.viewIndex
|
|
118
|
-
: undefined;
|
|
119
|
-
if (idx === undefined || !req.config || !req.config.views) return undefined;
|
|
120
|
-
return req.config.views[idx];
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
// Routes that mutate data and reference their target view via viewIndex
|
|
124
|
-
// (body for POST bodies, query for /uploadFile). Import routes carry the
|
|
125
|
-
// viewIndex inside a multipart body and are guarded inline in their handlers.
|
|
126
|
-
const MUTATING_VIEW_ROUTES = new Set([
|
|
127
|
-
"/updateRecord",
|
|
128
|
-
"/createRecord",
|
|
129
|
-
"/deleteRecord",
|
|
130
|
-
"/deleteRecords",
|
|
131
|
-
"/deleteRecordFromPanel",
|
|
132
|
-
"/customAction",
|
|
133
|
-
"/uploadFile",
|
|
134
|
-
]);
|
|
135
|
-
// /duplicateRecord references its table via req.body.table (not a viewIndex),
|
|
136
|
-
// so it is guarded inline in its handler instead of by the generic middleware.
|
|
137
|
-
|
|
138
|
-
// Column names are interpolated into INSERT/UPDATE statements; only accept plain
|
|
139
|
-
// SQL identifiers so a crafted key cannot break out of the column list.
|
|
140
|
-
const areSafeColumnNames = (data) =>
|
|
141
|
-
!data || Object.keys(data).every((k) => /^[A-Za-z0-9_]+$/.test(k));
|
|
142
|
-
|
|
143
|
-
// Tables the current role is allowed to write, gathered from its writable views
|
|
144
|
-
// and their creatable panels — used to bound /duplicateRecord's target table.
|
|
145
|
-
const writableTables = (req) => {
|
|
146
|
-
const role = roleOf(req);
|
|
147
|
-
const tables = new Set<string>();
|
|
148
|
-
for (const view of req.config?.views || []) {
|
|
149
|
-
if (!canWriteView(view, role)) continue;
|
|
150
|
-
if (view.tableName) tables.add(view.tableName);
|
|
151
|
-
for (const panel of view.recordViewPanels || []) {
|
|
152
|
-
if (panel.tableName) tables.add(panel.tableName);
|
|
153
|
-
if (panel.createConfig?.tableName)
|
|
154
|
-
tables.add(panel.createConfig.tableName);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
return tables;
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
// Reject client-supplied SQL fragments (the `filters` array is concatenated
|
|
161
|
-
// raw into the WHERE clause). With multipleStatements off this already blocks
|
|
162
|
-
// stacked writes; the pattern also blocks UNION/comment/file exfil breakouts.
|
|
163
|
-
const looksLikeSqlBreakout = (frag) =>
|
|
164
|
-
typeof frag === "string" &&
|
|
165
|
-
/(;|--|\/\*|\bunion\b|\binto\s+(out|dump)file\b|\bsleep\s*\(|\bbenchmark\s*\(|\bload_file\s*\()/i.test(
|
|
166
|
-
frag,
|
|
167
|
-
);
|
|
168
275
|
|
|
169
276
|
app.use((req, res, next) => {
|
|
170
277
|
const role = roleOf(req);
|
|
@@ -217,7 +324,14 @@ export default (db, config) => {
|
|
|
217
324
|
.map((x) => quoteIdentifier(x, db.dbType))
|
|
218
325
|
.join(".");
|
|
219
326
|
}
|
|
220
|
-
|
|
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 + "%'";
|
|
221
335
|
isNewCondition = false;
|
|
222
336
|
}
|
|
223
337
|
}
|
|
@@ -319,6 +433,7 @@ export default (db, config) => {
|
|
|
319
433
|
res.send(output[0].code);
|
|
320
434
|
} catch (err) {
|
|
321
435
|
console.error(err);
|
|
436
|
+
res.sendStatus(500);
|
|
322
437
|
}
|
|
323
438
|
});
|
|
324
439
|
|
|
@@ -333,27 +448,6 @@ export default (db, config) => {
|
|
|
333
448
|
});
|
|
334
449
|
});
|
|
335
450
|
|
|
336
|
-
const sqlAnd = (conditions) => {
|
|
337
|
-
const existingConditions = [];
|
|
338
|
-
for (const condition of conditions) {
|
|
339
|
-
if (condition && condition.length > 0) {
|
|
340
|
-
existingConditions.push(condition);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
if (existingConditions.length <= 1) {
|
|
344
|
-
return existingConditions[0] || null;
|
|
345
|
-
} else {
|
|
346
|
-
let result = "";
|
|
347
|
-
for (let i = 0; i < existingConditions.length; i++) {
|
|
348
|
-
if (i > 0) {
|
|
349
|
-
result += " AND ";
|
|
350
|
-
}
|
|
351
|
-
result += "(" + existingConditions[i] + ")";
|
|
352
|
-
}
|
|
353
|
-
return result;
|
|
354
|
-
}
|
|
355
|
-
};
|
|
356
|
-
|
|
357
451
|
app.post("/foreignSelectRecords", async (req, res, next) => {
|
|
358
452
|
const view = req.config.views[req.body.viewIndex];
|
|
359
453
|
const column = [
|
|
@@ -479,86 +573,6 @@ export default (db, config) => {
|
|
|
479
573
|
|
|
480
574
|
const formattedValueToFunction = {};
|
|
481
575
|
|
|
482
|
-
async function getFormattedValue(column, record) {
|
|
483
|
-
let value;
|
|
484
|
-
try {
|
|
485
|
-
record.options = column.options;
|
|
486
|
-
if (column.formatValueForExport) {
|
|
487
|
-
value = await column.formatValueForExport(record);
|
|
488
|
-
} else if (column.formattedValue) {
|
|
489
|
-
value = evalExpression(column.formattedValue, record);
|
|
490
|
-
} else if (column.formatValue) {
|
|
491
|
-
value = await column.formatValue(record);
|
|
492
|
-
} else if (column.dataType == "tinyint") {
|
|
493
|
-
value = record[column.name] == 1 ? "Oui" : "Non";
|
|
494
|
-
} else {
|
|
495
|
-
value = record[column.name];
|
|
496
|
-
}
|
|
497
|
-
return value === null || typeof value == "undefined" ? "" : value;
|
|
498
|
-
} catch (err) {
|
|
499
|
-
return value || "";
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
function evalExpression(expression, data) {
|
|
504
|
-
let varString = "";
|
|
505
|
-
let statement = "";
|
|
506
|
-
|
|
507
|
-
try {
|
|
508
|
-
for (let key in data) {
|
|
509
|
-
statement =
|
|
510
|
-
"var " +
|
|
511
|
-
key +
|
|
512
|
-
" = " +
|
|
513
|
-
(typeof data[key] === "string"
|
|
514
|
-
? "'" +
|
|
515
|
-
data[key]
|
|
516
|
-
.replace(/'/g, "\\'")
|
|
517
|
-
.replace(/\n/g, "")
|
|
518
|
-
.replace(/\r/g, "") +
|
|
519
|
-
"'"
|
|
520
|
-
: typeof data[key] === "object"
|
|
521
|
-
? JSON.stringify(data[key])
|
|
522
|
-
: data[key]) +
|
|
523
|
-
";";
|
|
524
|
-
varString += statement;
|
|
525
|
-
}
|
|
526
|
-
const result = eval(varString + expression);
|
|
527
|
-
return result;
|
|
528
|
-
} catch (e) {
|
|
529
|
-
console.log("Problème d'évaluation de l'expression", statement);
|
|
530
|
-
console.error(e);
|
|
531
|
-
return "Error";
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function formatValueForInsertion(value: any, dbType: string) {
|
|
536
|
-
if (value !== 0 && !value) {
|
|
537
|
-
return "NULL";
|
|
538
|
-
}
|
|
539
|
-
if (value instanceof Date) {
|
|
540
|
-
return value.toISOString();
|
|
541
|
-
}
|
|
542
|
-
if (typeof value === "string") {
|
|
543
|
-
if (dbType === "POSTGRESQL") {
|
|
544
|
-
return "'" + value.replace(/'/g, "''") + "'";
|
|
545
|
-
} else {
|
|
546
|
-
return "'" + value.replace(/'/g, "\\'") + "'";
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
if (typeof value === "object") {
|
|
550
|
-
return "'" + JSON.stringify(value).replace(/'/g, "\\'") + "'";
|
|
551
|
-
}
|
|
552
|
-
if (typeof value === "boolean") {
|
|
553
|
-
return value ? "TRUE" : "FALSE";
|
|
554
|
-
}
|
|
555
|
-
if (typeof value === "number") {
|
|
556
|
-
return value;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
return value;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
576
|
app.post("/recordsExport", async function (req, res) {
|
|
563
577
|
const view = req.config.views[req.body.viewIndex];
|
|
564
578
|
const orderBy = req.body.orderBy || view.orderBy;
|
|
@@ -571,7 +585,19 @@ export default (db, config) => {
|
|
|
571
585
|
table: view.tableName,
|
|
572
586
|
page: 1,
|
|
573
587
|
allRecords: true,
|
|
574
|
-
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
|
+
]),
|
|
575
601
|
having: subview?.having,
|
|
576
602
|
customQuery: view.customQuery,
|
|
577
603
|
join: view.join,
|
|
@@ -623,6 +649,11 @@ export default (db, config) => {
|
|
|
623
649
|
}
|
|
624
650
|
// Get viewIndex in fields
|
|
625
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
|
+
}
|
|
626
657
|
if (typeof view.parseImportFile !== "function") {
|
|
627
658
|
return res
|
|
628
659
|
.status(500)
|
|
@@ -714,7 +745,19 @@ export default (db, config) => {
|
|
|
714
745
|
}
|
|
715
746
|
// Get viewIndex and panelIndex in fields
|
|
716
747
|
const view = req.config.views[fields.viewIndex[0]];
|
|
717
|
-
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
|
+
}
|
|
718
761
|
if (typeof panel.parseImportFile !== "function") {
|
|
719
762
|
return res
|
|
720
763
|
.status(500)
|
|
@@ -999,9 +1042,12 @@ export default (db, config) => {
|
|
|
999
1042
|
|
|
1000
1043
|
app.post("/deleteRecords", async function (req, res, next) {
|
|
1001
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(", ");
|
|
1002
1048
|
const { err } = await db.query(
|
|
1003
|
-
`DELETE FROM ?? WHERE ?? IN(${
|
|
1004
|
-
[view.tableName, view.primaryId],
|
|
1049
|
+
`DELETE FROM ?? WHERE ?? IN(${placeholders})`,
|
|
1050
|
+
[view.tableName, view.primaryId, ...req.body.primaryValues],
|
|
1005
1051
|
);
|
|
1006
1052
|
if (err) {
|
|
1007
1053
|
return res.status(500).send(err);
|
|
@@ -1099,9 +1145,11 @@ export default (db, config) => {
|
|
|
1099
1145
|
|
|
1100
1146
|
app.post("/createRecord", async function (req, res, next) {
|
|
1101
1147
|
const view = req.config.views[req.body.viewIndex];
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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;
|
|
1105
1153
|
try {
|
|
1106
1154
|
for (let key in req.body.data) {
|
|
1107
1155
|
if (
|
|
@@ -1165,12 +1213,19 @@ export default (db, config) => {
|
|
|
1165
1213
|
} catch (e) {}
|
|
1166
1214
|
}
|
|
1167
1215
|
if (panel ? panel.createCallbackFunction : view.createCallbackFunction) {
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
)
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
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
|
+
}
|
|
1174
1229
|
}
|
|
1175
1230
|
res.send({
|
|
1176
1231
|
id: r.insertId,
|
|
@@ -1184,30 +1239,6 @@ export default (db, config) => {
|
|
|
1184
1239
|
}
|
|
1185
1240
|
});
|
|
1186
1241
|
|
|
1187
|
-
// Collect every SQL template declared anywhere in the (role-filtered) config:
|
|
1188
|
-
// panel `query`, `autocompleteListQuery`, etc. customQuery may only run a
|
|
1189
|
-
// template that already exists in the config the caller is allowed to see —
|
|
1190
|
-
// so it can never be turned into an arbitrary read/write primitive.
|
|
1191
|
-
function collectAllowedQueries(node, acc = new Set<string>()) {
|
|
1192
|
-
if (!node || typeof node !== "object") return acc;
|
|
1193
|
-
if (Array.isArray(node)) {
|
|
1194
|
-
for (const item of node) collectAllowedQueries(item, acc);
|
|
1195
|
-
return acc;
|
|
1196
|
-
}
|
|
1197
|
-
for (const key of Object.keys(node)) {
|
|
1198
|
-
const value = node[key];
|
|
1199
|
-
if (
|
|
1200
|
-
typeof value === "string" &&
|
|
1201
|
-
(key === "query" || /Query$/.test(key))
|
|
1202
|
-
) {
|
|
1203
|
-
acc.add(value.trim());
|
|
1204
|
-
} else if (value && typeof value === "object") {
|
|
1205
|
-
collectAllowedQueries(value, acc);
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
return acc;
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
1242
|
// Attack-surface reduction: only expose /customQuery at all when the config
|
|
1212
1243
|
// actually declares SQL templates (panels, autocomplete lists, …). If nothing
|
|
1213
1244
|
// uses it, the route is replaced by an explicit "disabled" stub so the raw-SQL
|
|
@@ -1257,7 +1288,7 @@ export default (db, config) => {
|
|
|
1257
1288
|
function uploadFile(column, fileParamName, req, res) {
|
|
1258
1289
|
if (column.s3Config || column.gcpConfig) {
|
|
1259
1290
|
const form = new multiparty.Form();
|
|
1260
|
-
form.parse(req, function (err, fields, files) {
|
|
1291
|
+
form.parse(req, async function (err, fields, files) {
|
|
1261
1292
|
if (err) {
|
|
1262
1293
|
return res.status(500).send(err);
|
|
1263
1294
|
}
|
|
@@ -1268,8 +1299,16 @@ export default (db, config) => {
|
|
|
1268
1299
|
const extension = originalFilename.match(/(?:\.([^.]+))?$/)[1];
|
|
1269
1300
|
let aspectRatio = null;
|
|
1270
1301
|
if (["png", "jpeg", "jpg", "gif"].includes(extension)) {
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
+
}
|
|
1273
1312
|
}
|
|
1274
1313
|
const originalFilenameWithoutExtension = originalFilename.replace(
|
|
1275
1314
|
"." + extension,
|
|
@@ -1442,6 +1481,13 @@ export default (db, config) => {
|
|
|
1442
1481
|
app.post(
|
|
1443
1482
|
"/uploadFileFromBetterRichTextInput",
|
|
1444
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
|
+
}
|
|
1445
1491
|
const fileUploadProperties = JSON.parse(
|
|
1446
1492
|
req.headers["file-upload-properties"] as string,
|
|
1447
1493
|
);
|
|
@@ -1498,7 +1544,28 @@ export default (db, config) => {
|
|
|
1498
1544
|
return app;
|
|
1499
1545
|
};
|
|
1500
1546
|
|
|
1501
|
-
|
|
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) {
|
|
1502
1569
|
const { rows: info, err } = await db.query(
|
|
1503
1570
|
"SELECT * FROM information_schema.columns WHERE table_schema = ?",
|
|
1504
1571
|
[config.mysql?.database || config.postgresql?.database],
|
|
@@ -1545,7 +1612,7 @@ async function enrichConfig(config, db) {
|
|
|
1545
1612
|
config.enriched = true;
|
|
1546
1613
|
}
|
|
1547
1614
|
|
|
1548
|
-
function enrichColumn(column, tableName, info) {
|
|
1615
|
+
export function enrichColumn(column, tableName, info) {
|
|
1549
1616
|
if (!column.__id__) {
|
|
1550
1617
|
column.__id__ = generateUniqueIdForObject(column);
|
|
1551
1618
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { render } from "@testing-library/react";
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
import App from "./App";
|
|
4
|
+
|
|
5
|
+
jest.mock("axios", () => ({
|
|
6
|
+
__esModule: true,
|
|
7
|
+
default: { defaults: {}, get: jest.fn(), post: jest.fn() },
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
// RichTextInput patches window.Quill at module load; Quill only exists in
|
|
11
|
+
// the real app via a CDN script tag.
|
|
12
|
+
jest.mock("./components/inputs/RichTextInput", () => ({
|
|
13
|
+
__esModule: true,
|
|
14
|
+
default: () => null,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// CRA's jest config resets mocks between tests, so behavior must be
|
|
18
|
+
// (re)declared here rather than in the mock factory.
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
axios.get.mockResolvedValue({ data: { isAuthenticated: false } });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("App", () => {
|
|
24
|
+
it("renders the login panel when the session is not authenticated", async () => {
|
|
25
|
+
const { findByText } = render(<App />);
|
|
26
|
+
expect(await findByText("Se connecter")).toBeTruthy();
|
|
27
|
+
});
|
|
28
|
+
});
|