@galadrim/galadmin 0.1.236 → 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 (64) 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 +240 -189
  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 +5 -0
  10. package/dist/utils/dbManager.js +146 -11
  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 +284 -217
  19. package/src/front/src/App.test.js +28 -0
  20. package/src/front/src/components/OutsideClickHandler.test.js +53 -0
  21. package/src/front/src/components/RedirectToFirstView.test.js +38 -0
  22. package/src/front/src/utils/checkRequiredColumns.test.js +46 -0
  23. package/src/front/src/utils/evalExpression.test.js +29 -0
  24. package/src/index.ts +1 -1
  25. package/src/utils/dbManager.ts +243 -102
  26. package/src/utils/imageDimensions.ts +112 -0
  27. package/tests/__mocks__/@aws-sdk/client-s3.js +6 -0
  28. package/tests/__mocks__/@aws-sdk/s3-request-presigner.js +3 -0
  29. package/tests/__mocks__/@google-cloud/storage.js +7 -0
  30. package/tests/__mocks__/argon2.js +3 -0
  31. package/tests/__mocks__/axios.js +7 -0
  32. package/tests/__mocks__/connect-pg-simple.js +24 -0
  33. package/tests/__mocks__/express-mysql-session.js +24 -0
  34. package/tests/__mocks__/mysql2/promise.js +3 -0
  35. package/tests/__mocks__/pg.js +3 -0
  36. package/tests/__mocks__/rollup.js +5 -0
  37. package/tests/__mocks__/ssh2-sftp-client.js +10 -0
  38. package/tests/api-helpers.test.ts +376 -0
  39. package/tests/dbManager.init.test.ts +129 -0
  40. package/tests/dbManager.test.ts +265 -0
  41. package/tests/edge-cases.test.ts +167 -0
  42. package/tests/helpers/app.ts +69 -0
  43. package/tests/helpers/fakeDb.ts +66 -0
  44. package/tests/helpers/fakePool.ts +76 -0
  45. package/tests/helpers/fixtures.ts +68 -0
  46. package/tests/imageDimensions.test.ts +111 -0
  47. package/tests/index.sessionStore.test.ts +19 -0
  48. package/tests/index.test.ts +363 -0
  49. package/tests/integration/__mocks__/axios.js +3 -0
  50. package/tests/integration/dbHarness.ts +381 -0
  51. package/tests/integration/dbManager.integration.test.ts +69 -0
  52. package/tests/integration/globalSetup.ts +43 -0
  53. package/tests/integration/globalTeardown.ts +42 -0
  54. package/tests/integration/routes.authorization.integration.test.ts +319 -0
  55. package/tests/integration/routes.config.integration.test.ts +189 -0
  56. package/tests/integration/routes.crud.integration.test.ts +805 -0
  57. package/tests/integration/routes.custom.integration.test.ts +376 -0
  58. package/tests/integration/routes.foreign.integration.test.ts +306 -0
  59. package/tests/integration/routes.importExport.integration.test.ts +404 -0
  60. package/tests/integration/routes.integration.test.ts +366 -0
  61. package/tests/integration/routes.postgresql.integration.test.ts +88 -0
  62. package/tests/integration/routes.records.integration.test.ts +634 -0
  63. package/tests/integration/routes.upload.integration.test.ts +425 -0
  64. 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
 
@@ -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
- // The config-filtering middleware above only decides which views the *client*
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
- 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 + "%'";
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([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
+ ]),
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.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
+ }
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(${req.body.primaryValues.map((x) => "'" + x + "'").join(",")})`,
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
- const panel = req.body.panelIndex
1103
- ? view.recordViewPanels[req.body.panelIndex]
1104
- : 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;
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
- await (
1169
- panel ? panel.createCallbackFunction : view.createCallbackFunction
1170
- )({
1171
- id: r.insertId,
1172
- data: req.body.data,
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
- const dimensions = sizeOf(files[fileParamName][0].path);
1272
- 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
+ }
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
- 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) {
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
+ });