@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.
Files changed (68) hide show
  1. package/.github/workflows/main.yml +33 -2
  2. package/README.md +65 -0
  3. package/dist/api.d.ts +16 -0
  4. package/dist/api.js +276 -215
  5. package/dist/api.js.map +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/tsconfig.tsbuildinfo +1 -1
  9. package/dist/utils/dbManager.d.ts +6 -0
  10. package/dist/utils/dbManager.js +150 -10
  11. package/dist/utils/dbManager.js.map +1 -1
  12. package/dist/utils/imageDimensions.d.ts +5 -0
  13. package/dist/utils/imageDimensions.js +98 -0
  14. package/dist/utils/imageDimensions.js.map +1 -0
  15. package/jest.config.js +43 -0
  16. package/jest.integration.config.js +16 -0
  17. package/package.json +30 -10
  18. package/src/api.ts +403 -303
  19. package/src/front/ckeditor5-build/node_modules/.bin/semver +12 -0
  20. package/src/front/ckeditor5-build/node_modules/.bin/semver.cmd +17 -0
  21. package/src/front/ckeditor5-build/node_modules/.bin/semver.ps1 +28 -0
  22. package/src/front/ckeditor5-build/node_modules/semver/bin/semver.js +0 -0
  23. package/src/front/src/App.test.js +28 -0
  24. package/src/front/src/components/OutsideClickHandler.test.js +53 -0
  25. package/src/front/src/components/RedirectToFirstView.test.js +38 -0
  26. package/src/front/src/utils/checkRequiredColumns.test.js +46 -0
  27. package/src/front/src/utils/evalExpression.test.js +29 -0
  28. package/src/index.ts +1 -1
  29. package/src/utils/dbManager.ts +248 -102
  30. package/src/utils/imageDimensions.ts +112 -0
  31. package/tests/__mocks__/@aws-sdk/client-s3.js +6 -0
  32. package/tests/__mocks__/@aws-sdk/s3-request-presigner.js +3 -0
  33. package/tests/__mocks__/@google-cloud/storage.js +7 -0
  34. package/tests/__mocks__/argon2.js +3 -0
  35. package/tests/__mocks__/axios.js +7 -0
  36. package/tests/__mocks__/connect-pg-simple.js +24 -0
  37. package/tests/__mocks__/express-mysql-session.js +24 -0
  38. package/tests/__mocks__/mysql2/promise.js +3 -0
  39. package/tests/__mocks__/pg.js +3 -0
  40. package/tests/__mocks__/rollup.js +5 -0
  41. package/tests/__mocks__/ssh2-sftp-client.js +10 -0
  42. package/tests/api-helpers.test.ts +376 -0
  43. package/tests/dbManager.init.test.ts +129 -0
  44. package/tests/dbManager.test.ts +265 -0
  45. package/tests/edge-cases.test.ts +167 -0
  46. package/tests/helpers/app.ts +69 -0
  47. package/tests/helpers/fakeDb.ts +66 -0
  48. package/tests/helpers/fakePool.ts +76 -0
  49. package/tests/helpers/fixtures.ts +68 -0
  50. package/tests/imageDimensions.test.ts +111 -0
  51. package/tests/index.sessionStore.test.ts +19 -0
  52. package/tests/index.test.ts +363 -0
  53. package/tests/integration/__mocks__/axios.js +3 -0
  54. package/tests/integration/dbHarness.ts +381 -0
  55. package/tests/integration/dbManager.integration.test.ts +69 -0
  56. package/tests/integration/globalSetup.ts +43 -0
  57. package/tests/integration/globalTeardown.ts +42 -0
  58. package/tests/integration/routes.authorization.integration.test.ts +319 -0
  59. package/tests/integration/routes.config.integration.test.ts +189 -0
  60. package/tests/integration/routes.crud.integration.test.ts +805 -0
  61. package/tests/integration/routes.custom.integration.test.ts +376 -0
  62. package/tests/integration/routes.foreign.integration.test.ts +306 -0
  63. package/tests/integration/routes.importExport.integration.test.ts +404 -0
  64. package/tests/integration/routes.integration.test.ts +366 -0
  65. package/tests/integration/routes.postgresql.integration.test.ts +88 -0
  66. package/tests/integration/routes.records.integration.test.ts +634 -0
  67. package/tests/integration/routes.upload.integration.test.ts +425 -0
  68. 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 sizeOf from "image-size";
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
- function generateUniqueIdForObject(obj) {
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(x => !x.roles
43
- || (req.session.user && ([...(x.roles || []), ...(x.readRoles || [])].includes(req.session.user.role)))
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 = req.session.user && view.roles && !view.roles.includes(req.session.user.role)
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(column => !column.roles || (req.session.user && column.roles.includes(req.session.user.role)))
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
- // The config-filtering middleware above only decides which views the *client*
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 === "POSTGRESQL" ? x : "`" + x + "`"))
324
+ .map((x) => quoteIdentifier(x, db.dbType))
202
325
  .join(".");
203
326
  }
204
- queryString += " LIKE '%" + params.query.replace(/'/g, "\\'") + "%'";
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(req.session.user.userId, req.session.user.role)
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(req.session.user.userId, req.session.user.role)
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([view.filters, subview?.filters, req.body.filters]),
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) => `"${x}"`).join(", ")}) VALUES`;
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 += `"${column}" = EXCLUDED."${column}",`;
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) => "`" + x + "`").join(", ") +
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 += " `" + column + "` = VALUES(`" + column + "`),";
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.recordViewPanels[fields.panelIndex[0]];
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) => "`" + x + "`").join(", ") +
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 += " `" + column + "` = VALUES(`" + column + "`),";
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
- (view.selectExpression || "*") +
779
- " FROM ?? " +
780
- (view.join || "") +
781
- " WHERE ?? = ?",
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
- (view.selectExpression || "*") +
866
- " FROM ?? " +
867
- (view.join || "") +
868
- " WHERE ?? = ?",
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(record, req.session.user.userId);
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
- (view.selectExpression || "*") +
898
- " FROM ?? " +
899
- (view.join || "") +
900
- " WHERE ?? = ?",
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(${req.body.primaryValues.map((x) => "'" + x + "'").join(",")})`,
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
- const panel = req.body.panelIndex
1078
- ? view.recordViewPanels[req.body.panelIndex]
1079
- : null;
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).join(", ");
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
- await (
1142
- panel ? panel.createCallbackFunction : view.createCallbackFunction
1143
- )({
1144
- id: r.insertId,
1145
- data: req.body.data,
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 = typeof req.query.query === "string" ? req.query.query : "";
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.status(400).send({ error: "Only read-only queries are allowed" });
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
- req.query.panelIndex as string
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
- const dimensions = sizeOf(files[fileParamName][0].path);
1239
- aspectRatio = dimensions.height / dimensions.width;
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
- (column.subdirectory ? "/" + column.subdirectory : "") +
1381
- "/" +
1382
- filename
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
- async function enrichConfig(config, db) {
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
  }