@ti-engine/web-framework 1.19.0 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +4 -4
- package/CHANGELOG.md +384 -353
- package/README.md +73 -73
- package/bin/build/post-install.js +18 -18
- package/bin/localization/web-server-labels.json +27 -27
- package/bin/static/.well-known/appspecific/com.chrome.devtools.json +5 -5
- package/bin/static/fragments/components/component-notification-bar.html +21 -21
- package/bin/static/fragments/components/component-sidebar.html +33 -33
- package/bin/static/fragments/components/component-tooltip.html +10 -10
- package/bin/static/fragments/components/component-topbar.html +5 -5
- package/bin/static/fragments/frame-administration.html +2 -2
- package/bin/static/fragments/frame-application.html +18 -18
- package/bin/static/fragments/frame-dashboard.html +2 -2
- package/bin/static/fragments/frame-login.html +119 -119
- package/bin/static/fragments/frame-not-found.html +2 -2
- package/bin/static/fragments/frame-profile.html +2 -2
- package/bin/static/index.html +22 -22
- package/bin/static/scripts/ti-charts.js +1591 -1591
- package/bin/static/scripts/ti-framework.css +3194 -3194
- package/bin/static/scripts/ti-framework.js +1427 -1427
- package/bin/static/scripts/ti-theme-black-glass.css +216 -216
- package/bin/static/scripts/ti-theme-daylight.css +87 -87
- package/bin/web-app-manager.js +660 -663
- package/bin/web-server.js +936 -937
- package/bin/web-server.json +48 -48
- package/components/admin-config-handlers.js +95 -92
- package/components/auth-manager.js +438 -442
- package/components/authorization.js +135 -135
- package/components/config-change-notifier.js +98 -98
- package/components/config-registry.js +257 -260
- package/components/config-service.js +363 -360
- package/components/config-store.js +244 -246
- package/components/definitions.types.js +28 -26
- package/components/session-store.js +113 -110
- package/components/user.js +134 -132
- package/components/web-config-env.js +85 -85
- package/components/web-handlers.js +803 -800
- package/package.json +139 -67
- package/types/bin/web-app-manager.d.ts +194 -0
- package/types/bin/web-server.d.ts +373 -0
- package/types/components/admin-config-handlers.d.ts +11 -0
- package/types/components/auth-manager.d.ts +125 -0
- package/types/components/authorization.d.ts +54 -0
- package/types/components/config-change-notifier.d.ts +73 -0
- package/types/components/config-registry.d.ts +149 -0
- package/types/components/config-service.d.ts +218 -0
- package/types/components/config-store.d.ts +128 -0
- package/types/components/definitions.types.d.ts +31 -0
- package/types/components/session-store.d.ts +56 -0
- package/types/components/user.d.ts +83 -0
- package/types/components/web-config-env.d.ts +17 -0
- package/types/components/web-handlers.d.ts +23 -0
|
@@ -1,800 +1,803 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
|
|
3
|
-
* Copyright © 2021-2025 Boris Kostadinov <kostadinov.boris@gmail.com>
|
|
4
|
-
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
5
|
-
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
6
|
-
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
const logger = require( "@ti-engine/core/logger" );
|
|
10
|
-
const exceptions = require( "@ti-engine/core/exceptions" );
|
|
11
|
-
const localization = require( "@ti-engine/core/localization" );
|
|
12
|
-
const { randomBytes, timingSafeEqual } = require( "node:crypto" );
|
|
13
|
-
const URL = require( "node:url" ).URL;
|
|
14
|
-
const _ = require( "lodash" );
|
|
15
|
-
const helmet = require( "helmet" );
|
|
16
|
-
const cache = require( "@ti-engine/core/cache" );
|
|
17
|
-
const authMethod = require( "#auth-manager" ).authMethod;
|
|
18
|
-
const authorization = require( "#authorization" );
|
|
19
|
-
|
|
20
|
-
/** @
|
|
21
|
-
/** @
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* @
|
|
30
|
-
* @
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* @
|
|
40
|
-
* @param {
|
|
41
|
-
* @
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
[ exceptions.exceptionCode.
|
|
54
|
-
[ exceptions.exceptionCode.
|
|
55
|
-
[ exceptions.exceptionCode.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
return
|
|
78
|
-
}
|
|
79
|
-
if ( code >=
|
|
80
|
-
return exceptions.httpCode.
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* @
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* @
|
|
108
|
-
* @
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* @
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* @
|
|
158
|
-
* @
|
|
159
|
-
* @
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* @
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* @
|
|
206
|
-
* @
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
* @
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* @
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
* @
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
response.
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* @
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* @
|
|
326
|
-
* @
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
};
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
* @
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
* @
|
|
450
|
-
*
|
|
451
|
-
* @
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
};
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
response.
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
};
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
* @
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
//
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
next(
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
* @
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
}
|
|
754
|
-
if (
|
|
755
|
-
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
* @
|
|
774
|
-
*
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
}
|
|
1
|
+
/*
|
|
2
|
+
* The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
|
|
3
|
+
* Copyright © 2021-2025 Boris Kostadinov <kostadinov.boris@gmail.com>
|
|
4
|
+
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
5
|
+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
6
|
+
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const logger = require( "@ti-engine/core/logger" );
|
|
10
|
+
const exceptions = require( "@ti-engine/core/exceptions" );
|
|
11
|
+
const localization = require( "@ti-engine/core/localization" );
|
|
12
|
+
const { randomBytes, timingSafeEqual } = require( "node:crypto" );
|
|
13
|
+
const URL = require( "node:url" ).URL;
|
|
14
|
+
const _ = require( "lodash" );
|
|
15
|
+
const helmet = require( "helmet" );
|
|
16
|
+
const cache = require( "@ti-engine/core/cache" );
|
|
17
|
+
const authMethod = require( "#auth-manager" ).authMethod;
|
|
18
|
+
const authorization = require( "#authorization" );
|
|
19
|
+
|
|
20
|
+
/** @import { TiAuthMethod } from "#auth-manager" */
|
|
21
|
+
/** @import TiWebServer from "#web-server" */
|
|
22
|
+
|
|
23
|
+
/** @typedef {import("express").Request} ExpressRequest */
|
|
24
|
+
/** @typedef {import("express").Response} ExpressResponse */
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Express middleware callback.
|
|
28
|
+
*
|
|
29
|
+
* @callback ExpressHandler
|
|
30
|
+
* @param {ExpressRequest} request
|
|
31
|
+
* @param {ExpressResponse} response
|
|
32
|
+
* @param {(error: Error|null) => void} next
|
|
33
|
+
* @returns {void}
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Express middleware callback with an error.
|
|
38
|
+
*
|
|
39
|
+
* @callback ExpressErrorHandler
|
|
40
|
+
* @param {Error} error
|
|
41
|
+
* @param {ExpressRequest} request
|
|
42
|
+
* @param {ExpressResponse} response
|
|
43
|
+
* @param {(error: Error|null) => void} next
|
|
44
|
+
* @returns {void}
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Default HTTP status for specific exception codes that do not carry an explicit `httpCode`.
|
|
49
|
+
*
|
|
50
|
+
* @type {Object<number, TiHttpCode>}
|
|
51
|
+
*/
|
|
52
|
+
const DEFAULT_HTTP_CODE_BY_EXCEPTION = {
|
|
53
|
+
[ exceptions.exceptionCode.E_WEB_INVALID_REQUEST_METHOD ]: exceptions.httpCode.C_405,
|
|
54
|
+
[ exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI ]: exceptions.httpCode.C_404,
|
|
55
|
+
[ exceptions.exceptionCode.E_WEB_INVALID_REQUEST_CONTENT_TYPE ]: exceptions.httpCode.C_415,
|
|
56
|
+
[ exceptions.exceptionCode.E_WEB_INVALID_REQUEST_CONTENT_LENGTH ]: exceptions.httpCode.C_413,
|
|
57
|
+
[ exceptions.exceptionCode.E_APP_RESOURCE_NOT_FOUND ]: exceptions.httpCode.C_404,
|
|
58
|
+
[ exceptions.exceptionCode.E_APP_RESOURCE_ALREADY_EXISTS ]: exceptions.httpCode.C_409
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolves the HTTP status to report for an exception. An explicit `httpCode` on the exception always wins; otherwise
|
|
63
|
+
* the status is derived from the exception code so that client and application errors surface as 4xx — request and
|
|
64
|
+
* application-logic failures as `422 Unprocessable Content`, security as `403` — instead of being misreported as a
|
|
65
|
+
* generic `500`. Only genuine internal, communication, and unknown errors default to `500`.
|
|
66
|
+
*
|
|
67
|
+
* @method
|
|
68
|
+
* @param {TiException} exception
|
|
69
|
+
* @returns {TiHttpCode}
|
|
70
|
+
*/
|
|
71
|
+
const resolveHttpCode = ( exception ) => {
|
|
72
|
+
if ( exception.httpCode ) {
|
|
73
|
+
return exception.httpCode;
|
|
74
|
+
}
|
|
75
|
+
const code = exception.code;
|
|
76
|
+
if ( DEFAULT_HTTP_CODE_BY_EXCEPTION[ code ] ) {
|
|
77
|
+
return DEFAULT_HTTP_CODE_BY_EXCEPTION[ code ];
|
|
78
|
+
}
|
|
79
|
+
if ( code >= 2000 && code < 3000 ) {
|
|
80
|
+
return exceptions.httpCode.C_403; // security / authorization
|
|
81
|
+
}
|
|
82
|
+
if ( code >= 4000 && code < 6000 ) {
|
|
83
|
+
return exceptions.httpCode.C_422; // request validation + application logic → unprocessable content
|
|
84
|
+
}
|
|
85
|
+
return exceptions.httpCode.C_500; // general, communication, or unknown → internal server error
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Used to assemble the current URL of a request.
|
|
90
|
+
*
|
|
91
|
+
* @method
|
|
92
|
+
* @param {ExpressRequest} request
|
|
93
|
+
* @returns {string}
|
|
94
|
+
* @private
|
|
95
|
+
*/
|
|
96
|
+
let getBaseUrl = ( request ) => {
|
|
97
|
+
const xfProtocol = String( request.get( "x-forwarded-proto" ) || "" ).toLowerCase();
|
|
98
|
+
const xfHost = request.get( "x-forwarded-host" );
|
|
99
|
+
const protocol = ( request.secure || xfProtocol === "https" ) ? "https" : "http";
|
|
100
|
+
const host = xfHost || request.get( "host" );
|
|
101
|
+
return `${ protocol }://${ host }`;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Timing-safe token comparison.
|
|
106
|
+
*
|
|
107
|
+
* @method
|
|
108
|
+
* @param {string} first
|
|
109
|
+
* @param {string} second
|
|
110
|
+
* @returns {boolean}
|
|
111
|
+
* @private
|
|
112
|
+
*/
|
|
113
|
+
let safeEquals = ( first, second ) => {
|
|
114
|
+
try {
|
|
115
|
+
const ba = Buffer.from( String( first || "" ) );
|
|
116
|
+
const bb = Buffer.from( String( second || "" ) );
|
|
117
|
+
return ( ba.length !== bb.length ) ? false : timingSafeEqual( ba, bb );
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Extract origin to validate. Prefer Origin, fallback to Referer origin.
|
|
125
|
+
*
|
|
126
|
+
* @method
|
|
127
|
+
* @param {ExpressRequest} request
|
|
128
|
+
* @returns {string|undefined} e.g., "https://example.com:8443"
|
|
129
|
+
* @private
|
|
130
|
+
*/
|
|
131
|
+
let getRequestOrigin = ( request ) => {
|
|
132
|
+
let result = undefined;
|
|
133
|
+
const rawOrigin = request.get( "origin" );
|
|
134
|
+
const origin = String( rawOrigin || "" ).trim().toLowerCase();
|
|
135
|
+
|
|
136
|
+
// Ignore explicit "null" or empty origins:
|
|
137
|
+
if ( origin && origin !== "null" ) {
|
|
138
|
+
result = rawOrigin;
|
|
139
|
+
} else {
|
|
140
|
+
const referer = request.get( "referer" );
|
|
141
|
+
if ( referer ) {
|
|
142
|
+
try {
|
|
143
|
+
const refererUrl = new URL( referer );
|
|
144
|
+
result = `${ refererUrl.protocol }//${ refererUrl.host }`;
|
|
145
|
+
} catch {
|
|
146
|
+
// do nothing here...
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return result;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Used to regenerate the session and save it.
|
|
156
|
+
*
|
|
157
|
+
* @method
|
|
158
|
+
* @param {ExpressRequest} request
|
|
159
|
+
* @param {string} redirectTo
|
|
160
|
+
* @param {(session: TiSession) => TiSession} modifier
|
|
161
|
+
* @returns {Promise<string>}
|
|
162
|
+
* @private
|
|
163
|
+
*/
|
|
164
|
+
let regenerateAndSaveSession = ( request, redirectTo, modifier ) => {
|
|
165
|
+
return new Promise( ( resolve, reject ) => {
|
|
166
|
+
request.session.regenerate( ( error ) => {
|
|
167
|
+
if ( error ) {
|
|
168
|
+
reject( error );
|
|
169
|
+
} else {
|
|
170
|
+
try {
|
|
171
|
+
if ( modifier && typeof modifier === "function" ) {
|
|
172
|
+
request.session = modifier( request.session );
|
|
173
|
+
}
|
|
174
|
+
} catch ( error ) {
|
|
175
|
+
reject( error );
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
request.session.save( ( error ) => {
|
|
179
|
+
if ( error ) {
|
|
180
|
+
reject( error );
|
|
181
|
+
} else {
|
|
182
|
+
resolve( redirectTo );
|
|
183
|
+
}
|
|
184
|
+
} )
|
|
185
|
+
}
|
|
186
|
+
} )
|
|
187
|
+
} );
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Check if the request is an HTMX request.
|
|
192
|
+
*
|
|
193
|
+
* @method
|
|
194
|
+
* @param {ExpressRequest} request
|
|
195
|
+
* @returns {boolean}
|
|
196
|
+
* @private
|
|
197
|
+
*/
|
|
198
|
+
let isHtmxRequest = ( request ) => {
|
|
199
|
+
return String( request.get( "HX-Request" ) || "" ).toLowerCase() === "true";
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Used to determine if the request accepts the specified response type.
|
|
204
|
+
*
|
|
205
|
+
* @method
|
|
206
|
+
* @param {ExpressRequest} request
|
|
207
|
+
* @param {string} type
|
|
208
|
+
* @return {boolean}
|
|
209
|
+
* @private
|
|
210
|
+
*/
|
|
211
|
+
let isAcceptingResponseType = ( request, type ) => {
|
|
212
|
+
const accept = String( request.get( "accept" ) || "" ).toLowerCase();
|
|
213
|
+
if ( accept ) {
|
|
214
|
+
return request.accepts( type ) === type;
|
|
215
|
+
} else {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Safely convert a URI to a string.
|
|
222
|
+
*
|
|
223
|
+
* @method
|
|
224
|
+
* @param {URL|string} uri
|
|
225
|
+
* @return {string}
|
|
226
|
+
* @private
|
|
227
|
+
*/
|
|
228
|
+
let convertUriToString = ( uri ) => {
|
|
229
|
+
return ( typeof uri === "string" ) ? uri : ( uri && typeof uri.toString === "function" ) ? uri.toString() : "/";
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Handler for requests that are received while the web server is shutting down.
|
|
234
|
+
*
|
|
235
|
+
* @method
|
|
236
|
+
* @param {TiWebServer} instance
|
|
237
|
+
* @returns {ExpressHandler}
|
|
238
|
+
* @public
|
|
239
|
+
*/
|
|
240
|
+
module.exports.onShutDownHandler = ( instance ) => {
|
|
241
|
+
return ( request, response, next ) => {
|
|
242
|
+
if ( !instance.isShuttingDown ) {
|
|
243
|
+
next();
|
|
244
|
+
} else {
|
|
245
|
+
response.set( "Connection", "close" );
|
|
246
|
+
response.status( exceptions.httpCode.C_503 ).end();
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Handler that verifies if the requested resource requires authentication or is freely accessible.
|
|
253
|
+
*
|
|
254
|
+
* @method
|
|
255
|
+
* @param {TiWebServer} instance
|
|
256
|
+
* @returns {ExpressHandler}
|
|
257
|
+
* @public
|
|
258
|
+
*/
|
|
259
|
+
module.exports.resourceProtectionHandler = ( instance ) => {
|
|
260
|
+
return ( request, response, next ) => {
|
|
261
|
+
if ( instance.isUnprotectedRoute( request.url ) || instance.verifySession( request.session ) ) {
|
|
262
|
+
next();
|
|
263
|
+
} else {
|
|
264
|
+
const redirectTo = "/";
|
|
265
|
+
if ( isHtmxRequest( request ) ) {
|
|
266
|
+
response.set( "HX-Redirect", redirectTo );
|
|
267
|
+
response.status( exceptions.httpCode.C_204 ).end();
|
|
268
|
+
} else if ( isAcceptingResponseType( request, "html" ) ) {
|
|
269
|
+
response.redirect( exceptions.httpCode.C_303, convertUriToString( redirectTo ) );
|
|
270
|
+
} else {
|
|
271
|
+
response.status( exceptions.httpCode.C_401 ).end();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Handler for server-side authentication.
|
|
279
|
+
*
|
|
280
|
+
* @method
|
|
281
|
+
* @param {TiWebServer} instance
|
|
282
|
+
* @returns {ExpressHandler}
|
|
283
|
+
* @public
|
|
284
|
+
*/
|
|
285
|
+
module.exports.authenticationHandler = ( instance ) => {
|
|
286
|
+
return ( request, response, next ) => {
|
|
287
|
+
const method = request.params.method;
|
|
288
|
+
if ( method === authMethod.LOCAL ) {
|
|
289
|
+
const username = String( ( request.body && request.body.username ) || "" ).trim();
|
|
290
|
+
const password = String( ( request.body && request.body.password ) || "" );
|
|
291
|
+
instance.authenticate( authMethod.LOCAL, { username: username, password: password } ).then( () => {
|
|
292
|
+
return instance.authorize( authMethod.LOCAL, new URL( request.originalUrl, getBaseUrl( request ) ), { username: username } );
|
|
293
|
+
} ).then( ( user ) => {
|
|
294
|
+
return regenerateAndSaveSession( request, "/", ( session ) => {
|
|
295
|
+
session.user = user.asJSON();
|
|
296
|
+
session.language = user.language || instance.serviceConfig.language;
|
|
297
|
+
|
|
298
|
+
return authorization.applyAdminRole( instance.augmentSession( session, request ), instance.serviceConfig?.auth?.admins );
|
|
299
|
+
} );
|
|
300
|
+
} ).then( ( redirectTo ) => {
|
|
301
|
+
response.redirect( exceptions.httpCode.C_303, convertUriToString( redirectTo ) );
|
|
302
|
+
} ).catch( ( error ) => {
|
|
303
|
+
next( exceptions.raise( error, null, exceptions.httpCode.C_401 ) );
|
|
304
|
+
} );
|
|
305
|
+
} else if ( method === authMethod.OPENID_GOOGLE || method === authMethod.OPENID_AZURE ) {
|
|
306
|
+
instance.authenticate( method, { baseUrl: getBaseUrl( request ) } ).then( ( result ) => {
|
|
307
|
+
return regenerateAndSaveSession( request, result.redirectTo, ( session ) => {
|
|
308
|
+
session.oidc = { codeVerifier: result.codeVerifier, state: result.state, nonce: result.nonce };
|
|
309
|
+
return session;
|
|
310
|
+
} );
|
|
311
|
+
} ).then( ( redirectTo ) => {
|
|
312
|
+
response.redirect( exceptions.httpCode.C_303, convertUriToString( redirectTo ) );
|
|
313
|
+
} ).catch( ( error ) => {
|
|
314
|
+
next( exceptions.raise( error, null, exceptions.httpCode.C_401 ) );
|
|
315
|
+
} );
|
|
316
|
+
} else {
|
|
317
|
+
next();
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Used to handle the callback from the Google OpenID authentication.
|
|
324
|
+
*
|
|
325
|
+
* @method
|
|
326
|
+
* @param {TiWebServer} instance
|
|
327
|
+
* @param {TiAuthMethod} authMethod
|
|
328
|
+
* @returns {ExpressHandler}
|
|
329
|
+
* @public
|
|
330
|
+
*/
|
|
331
|
+
module.exports.authorizedOAuth2CallbackHandler = ( instance, authMethod ) => {
|
|
332
|
+
return ( request, response, next ) => {
|
|
333
|
+
const code = request.query.code;
|
|
334
|
+
const state = request.query.state;
|
|
335
|
+
const oidc = request.session.oidc || {};
|
|
336
|
+
if ( !code || !oidc?.codeVerifier ) {
|
|
337
|
+
response.status( exceptions.httpCode.C_400 ).end();
|
|
338
|
+
} else if ( oidc.state && state !== oidc.state ) {
|
|
339
|
+
response.status( exceptions.httpCode.C_400 ).end();
|
|
340
|
+
} else {
|
|
341
|
+
instance.authorize( authMethod, new URL( request.originalUrl, getBaseUrl( request ) ), oidc ).then( ( user ) => {
|
|
342
|
+
return regenerateAndSaveSession( request, "/", ( session ) => {
|
|
343
|
+
session.user = user.asJSON();
|
|
344
|
+
session.language = user.language || instance.serviceConfig.language;
|
|
345
|
+
|
|
346
|
+
delete session.oidc;
|
|
347
|
+
|
|
348
|
+
return authorization.applyAdminRole( instance.augmentSession( session, request ), instance.serviceConfig?.auth?.admins );
|
|
349
|
+
} );
|
|
350
|
+
} ).then( ( redirectTo ) => {
|
|
351
|
+
response.redirect( exceptions.httpCode.C_303, convertUriToString( redirectTo ) );
|
|
352
|
+
} ).catch( ( error ) => {
|
|
353
|
+
next( exceptions.raise( error, null, exceptions.httpCode.C_401 ) );
|
|
354
|
+
} );
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Handler for server-side logout.
|
|
361
|
+
*
|
|
362
|
+
* @method
|
|
363
|
+
* @returns {ExpressHandler}
|
|
364
|
+
* @public
|
|
365
|
+
*/
|
|
366
|
+
module.exports.logoutHandler = () => {
|
|
367
|
+
return ( request, response, next ) => {
|
|
368
|
+
const done = ( error ) => {
|
|
369
|
+
response.redirect( exceptions.httpCode.C_303, "/" );
|
|
370
|
+
};
|
|
371
|
+
if ( request.session ) {
|
|
372
|
+
request.session.destroy( done );
|
|
373
|
+
} else {
|
|
374
|
+
done();
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Handler for a lightweight, unauthenticated health probe. Responds `200` whenever the web server is serving
|
|
381
|
+
* (a liveness signal for container/orchestrator probes), and reports the message-broker (Redis) connection state
|
|
382
|
+
* in the body so it can double as a readiness signal without hitting a user-facing route like the login page.
|
|
383
|
+
*
|
|
384
|
+
* @method
|
|
385
|
+
* @returns {ExpressHandler}
|
|
386
|
+
* @public
|
|
387
|
+
*/
|
|
388
|
+
module.exports.healthHandler = () => {
|
|
389
|
+
return ( request, response ) => {
|
|
390
|
+
const broker = ( cache.instance && cache.instance.isOperational === true ) ? "connected" : "disconnected";
|
|
391
|
+
response.status( exceptions.httpCode.C_200 ).send( {
|
|
392
|
+
isSuccessful: true,
|
|
393
|
+
data: {
|
|
394
|
+
status: "ok",
|
|
395
|
+
broker: broker,
|
|
396
|
+
uptime: Math.round( process.uptime() )
|
|
397
|
+
}
|
|
398
|
+
} );
|
|
399
|
+
};
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Handler for retrieving authenticated user information.
|
|
404
|
+
*
|
|
405
|
+
* @method
|
|
406
|
+
* @returns {ExpressHandler}
|
|
407
|
+
* @public
|
|
408
|
+
*/
|
|
409
|
+
module.exports.userInformationHandler = () => {
|
|
410
|
+
return ( request, response, next ) => {
|
|
411
|
+
if ( request.session && request.session.user ) {
|
|
412
|
+
response.status( exceptions.httpCode.C_200 ).send( { isSuccessful: true, data: { user: _.cloneDeep( request.session.user ) } } );
|
|
413
|
+
} else {
|
|
414
|
+
next( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_401 ) );
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Handler for redirecting HTTP requests to HTTPS. Also works behind proxies using X-Forwarded-Proto.
|
|
421
|
+
*
|
|
422
|
+
* @method
|
|
423
|
+
* @param {TiWebServer} instance
|
|
424
|
+
* @returns {ExpressHandler}
|
|
425
|
+
* @public
|
|
426
|
+
*/
|
|
427
|
+
module.exports.httpRedirectHandler = ( instance ) => {
|
|
428
|
+
return ( request, response, next ) => {
|
|
429
|
+
const xfProto = String( request.get ? request.get( "x-forwarded-proto" ) : ( request.headers[ "x-forwarded-proto" ] || "" ) ).toLowerCase();
|
|
430
|
+
const isSecure = request.secure === true || xfProto === "https";
|
|
431
|
+
if ( isSecure ) {
|
|
432
|
+
next();
|
|
433
|
+
} else {
|
|
434
|
+
if ( instance.isAllowedHost( request.hostname ) !== true ) {
|
|
435
|
+
response.status( exceptions.httpCode.C_404 ).end();
|
|
436
|
+
} else {
|
|
437
|
+
const host = request.get ? request.get( "host" ) : request.headers.host;
|
|
438
|
+
const location = new URL( request.url, "https://" + host );
|
|
439
|
+
response.set( "Cache-Control", "no-store" );
|
|
440
|
+
response.redirect( exceptions.httpCode.C_308, convertUriToString( location ) );
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Handler for processing of API service calls.
|
|
448
|
+
* <br/>
|
|
449
|
+
* NOTE: This will send a new {@link ServiceCall} to the microservice network handled by the ti-engine.
|
|
450
|
+
*
|
|
451
|
+
* @method
|
|
452
|
+
* @param {TiWebServer} instance
|
|
453
|
+
* @returns {ExpressHandler}
|
|
454
|
+
* @public
|
|
455
|
+
*/
|
|
456
|
+
module.exports.serviceCallHandler = ( instance ) => {
|
|
457
|
+
return ( request, response, next ) => {
|
|
458
|
+
let serviceAddress = instance.getServiceAddress( request.params.version, request.params.name );
|
|
459
|
+
if ( !serviceAddress ) {
|
|
460
|
+
next( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI, null, exceptions.httpCode.C_404 ) );
|
|
461
|
+
} else {
|
|
462
|
+
request.setTimeout( instance.serviceConfig.api.requestTimeout );
|
|
463
|
+
instance.callService( serviceAddress, request.body || {}, {
|
|
464
|
+
authToken: request.sessionID,
|
|
465
|
+
} ).then( ( result ) => {
|
|
466
|
+
if ( result.isSuccessful !== true ) {
|
|
467
|
+
next( exceptions.raise( result.exception || exceptions.exceptionCode.E_COM_SERVICE_EXEC_FAILED, null, result.exception?.httpCode || exceptions.httpCode.C_400 ) );
|
|
468
|
+
} else {
|
|
469
|
+
response.status( exceptions.httpCode.C_200 ).send( result );
|
|
470
|
+
}
|
|
471
|
+
} ).catch( ( error ) => {
|
|
472
|
+
next( error );
|
|
473
|
+
} );
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Handler to intercept and handle all remaining requests to invalid URLs.
|
|
480
|
+
*
|
|
481
|
+
* @method
|
|
482
|
+
* @returns {ExpressHandler}
|
|
483
|
+
* @public
|
|
484
|
+
*/
|
|
485
|
+
module.exports.invalidRouteHandler = () => {
|
|
486
|
+
return ( request, response, next ) => {
|
|
487
|
+
next( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI, null, exceptions.httpCode.C_404 ) );
|
|
488
|
+
};
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Handler to intercept any errors that have not been resolved by previous middleware. Should be the last in the sequence.
|
|
493
|
+
*
|
|
494
|
+
* @method
|
|
495
|
+
* @returns {ExpressErrorHandler}
|
|
496
|
+
* @public
|
|
497
|
+
*/
|
|
498
|
+
module.exports.defaultErrorHandler = () => {
|
|
499
|
+
return ( error, request, response, next ) => {
|
|
500
|
+
const exception = exceptions.raise( error );
|
|
501
|
+
const payload = {
|
|
502
|
+
isSuccessful: false,
|
|
503
|
+
exception: exception.asJSON(),
|
|
504
|
+
message: localization.getLabel( exception.label, request.session?.language )
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
if ( exception.httpCode === exceptions.httpCode.C_404 ) {
|
|
508
|
+
logger.log( `Received request to an invalid route: "${ request.originalUrl }"`, logger.logSeverity.DEBUG, exception );
|
|
509
|
+
|
|
510
|
+
if ( isHtmxRequest( request ) ) {
|
|
511
|
+
response.set( "HX-Redirect", "/not-found" );
|
|
512
|
+
response.status( exceptions.httpCode.C_204 ).end();
|
|
513
|
+
} else if ( isAcceptingResponseType( request, "html" ) && request.method === "GET" ) {
|
|
514
|
+
response.redirect( exceptions.httpCode.C_303, "/not-found" );
|
|
515
|
+
} else {
|
|
516
|
+
response.status( exceptions.httpCode.C_404 ).send( payload );
|
|
517
|
+
}
|
|
518
|
+
} else {
|
|
519
|
+
logger.log( "Received request caused an exception.", logger.logSeverity.DEBUG, exception );
|
|
520
|
+
const status = resolveHttpCode( exception );
|
|
521
|
+
|
|
522
|
+
if ( isHtmxRequest( request ) ) {
|
|
523
|
+
response.set( {
|
|
524
|
+
"HX-Reswap": "none",
|
|
525
|
+
"HX-Retarget": "#ti-notifications",
|
|
526
|
+
"HX-Trigger": JSON.stringify( {
|
|
527
|
+
"ti:error": payload
|
|
528
|
+
} )
|
|
529
|
+
} );
|
|
530
|
+
return response.status( status ).send( "" );
|
|
531
|
+
} else if ( isAcceptingResponseType( request, "html" ) && request.method === "GET" ) {
|
|
532
|
+
response.redirect( exceptions.httpCode.C_303, "/?error=" + encodeURIComponent( exception.code ) );
|
|
533
|
+
} else {
|
|
534
|
+
response.status( status ).send( payload );
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Handler for generating a nonce for CSP.
|
|
542
|
+
*
|
|
543
|
+
* @method
|
|
544
|
+
* @returns {ExpressHandler}
|
|
545
|
+
* @public
|
|
546
|
+
*/
|
|
547
|
+
module.exports.nonceGenerationHandler = () => {
|
|
548
|
+
return ( request, response, next ) => {
|
|
549
|
+
if ( request.method === "GET" || request.method === "HEAD" ) {
|
|
550
|
+
try {
|
|
551
|
+
const nonce = randomBytes( 16 ).toString( "base64" );
|
|
552
|
+
request.cspNonce = nonce;
|
|
553
|
+
request.nonce = request.nonce || nonce;
|
|
554
|
+
response.locals = response.locals || {};
|
|
555
|
+
response.locals.cspNonce = nonce;
|
|
556
|
+
response.locals.nonce = response.locals.nonce || nonce;
|
|
557
|
+
next();
|
|
558
|
+
} catch ( error ) {
|
|
559
|
+
next( error );
|
|
560
|
+
}
|
|
561
|
+
} else {
|
|
562
|
+
next();
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Handler for setting the Content-Security-Policy header.
|
|
569
|
+
*
|
|
570
|
+
* @method
|
|
571
|
+
* @returns {ExpressHandler}
|
|
572
|
+
* @public
|
|
573
|
+
*/
|
|
574
|
+
module.exports.cspHeaderHandler = () => {
|
|
575
|
+
return ( request, response, next ) => {
|
|
576
|
+
const nonce = response?.locals?.cspNonce;
|
|
577
|
+
|
|
578
|
+
// Build script-src directive:
|
|
579
|
+
const scriptSrc = [ "'strict-dynamic'", "'self'", "https:" ];
|
|
580
|
+
if ( nonce ) {
|
|
581
|
+
scriptSrc.push( `'nonce-${ nonce }'` );
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Build style-src-elem directive:
|
|
585
|
+
const styleSrcElem = [ "'self'", "https:" ];
|
|
586
|
+
if ( nonce ) {
|
|
587
|
+
styleSrcElem.push( `'nonce-${ nonce }'` );
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Build directives object:
|
|
591
|
+
const directives = {
|
|
592
|
+
defaultSrc: [ "'self'" ],
|
|
593
|
+
scriptSrc: scriptSrc,
|
|
594
|
+
styleSrc: [ "'self'", "https:" ],
|
|
595
|
+
styleSrcElem: styleSrcElem,
|
|
596
|
+
imgSrc: [ "'self'", "data:", "https:" ],
|
|
597
|
+
connectSrc: [ "'self'", "https:", "ws:", "wss:" ],
|
|
598
|
+
fontSrc: [ "'self'", "https:", "data:" ],
|
|
599
|
+
objectSrc: [ "'none'" ],
|
|
600
|
+
frameAncestors: [ "'self'" ]
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const csp = helmet.contentSecurityPolicy( {
|
|
604
|
+
useDefaults: true,
|
|
605
|
+
directives
|
|
606
|
+
} );
|
|
607
|
+
return csp( request, response, next );
|
|
608
|
+
};
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Handler for requests that should be processed by the web application manager.
|
|
613
|
+
*
|
|
614
|
+
* @method
|
|
615
|
+
* @param {TiWebServer} instance
|
|
616
|
+
* @returns {ExpressHandler}
|
|
617
|
+
* @public
|
|
618
|
+
*/
|
|
619
|
+
module.exports.webAppHandler = ( instance ) => {
|
|
620
|
+
return ( request, response, next ) => {
|
|
621
|
+
/**
|
|
622
|
+
* @param {TiException} exception
|
|
623
|
+
* @return {TiException}
|
|
624
|
+
*/
|
|
625
|
+
const formatException = ( exception ) => {
|
|
626
|
+
exception.httpCode = resolveHttpCode( exception );
|
|
627
|
+
return exception;
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
if ( request.method === "GET" || request.method === "HEAD" ) {
|
|
631
|
+
if ( isAcceptingResponseType( request, "html" ) ) {
|
|
632
|
+
// HEAD: set headers only:
|
|
633
|
+
if ( request.method === "HEAD" ) {
|
|
634
|
+
response.set( "Cache-Control", "no-store" );
|
|
635
|
+
response.set( "Content-Type", "text/html; charset=utf-8" );
|
|
636
|
+
response.status( exceptions.httpCode.C_200 ).end();
|
|
637
|
+
} else {
|
|
638
|
+
// GET: load and render:
|
|
639
|
+
const resLocals = ( response && response.locals ) || {};
|
|
640
|
+
const isPartial = isHtmxRequest( request );
|
|
641
|
+
const nonceHeader = request.get( "x-csp-nonce" ) || "";
|
|
642
|
+
const nonce = isPartial ? nonceHeader : ( request.cspNonce || request.nonce || resLocals.cspNonce || resLocals.nonce );
|
|
643
|
+
instance.webAppManager.assembleHtmlView( request.session, instance.staticContentPaths, request.path, {
|
|
644
|
+
nonce: nonce,
|
|
645
|
+
isPartial: isPartial,
|
|
646
|
+
view: request.params.view,
|
|
647
|
+
csrfToken: request.session?.csrfToken
|
|
648
|
+
} ).then( ( html ) => {
|
|
649
|
+
response.set( "Cache-Control", "no-store" );
|
|
650
|
+
response.set( "Content-Type", "text/html; charset=utf-8" );
|
|
651
|
+
response.status( exceptions.httpCode.C_200 ).send( html );
|
|
652
|
+
} ).catch( ( error ) => {
|
|
653
|
+
next( formatException( exceptions.raise( error ) ) );
|
|
654
|
+
} );
|
|
655
|
+
}
|
|
656
|
+
} else if ( isAcceptingResponseType( request, "json" ) ) {
|
|
657
|
+
const requestContext = {
|
|
658
|
+
query: request.query,
|
|
659
|
+
params: request.params,
|
|
660
|
+
headers: request.headers,
|
|
661
|
+
url: request.originalUrl,
|
|
662
|
+
method: request.method
|
|
663
|
+
};
|
|
664
|
+
instance.webAppManager.processDataRequest( request.session, request.params?.view, requestContext ).then( ( result ) => {
|
|
665
|
+
response.set( "Cache-Control", "no-store" );
|
|
666
|
+
response.set( "Content-Type", "application/json; charset=utf-8" );
|
|
667
|
+
response.status( exceptions.httpCode.C_200 ).send( { isSuccessful: true, data: result } );
|
|
668
|
+
} ).catch( ( error ) => {
|
|
669
|
+
next( formatException( exceptions.raise( error ) ) );
|
|
670
|
+
} );
|
|
671
|
+
} else {
|
|
672
|
+
next();
|
|
673
|
+
}
|
|
674
|
+
} else if ( request.method === "POST" ) {
|
|
675
|
+
instance.webAppManager.processServiceRequest( request.session, request.params?.service, request.body || {} ).then( ( result ) => {
|
|
676
|
+
response.set( "Cache-Control", "no-store" );
|
|
677
|
+
response.set( "Content-Type", "application/json; charset=utf-8" );
|
|
678
|
+
response.status( exceptions.httpCode.C_200 ).send( { isSuccessful: true, data: result } );
|
|
679
|
+
} ).catch( ( error ) => {
|
|
680
|
+
next( formatException( exceptions.raise( error ) ) );
|
|
681
|
+
} );
|
|
682
|
+
} else {
|
|
683
|
+
next();
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Validate Origin/Referer for non-GET/HEAD/OPTIONS requests.
|
|
690
|
+
* Origin must match the current request origin (protocol + host[:port]).
|
|
691
|
+
*
|
|
692
|
+
* @method
|
|
693
|
+
* @returns {ExpressHandler}
|
|
694
|
+
* @public
|
|
695
|
+
*/
|
|
696
|
+
module.exports.originRefererValidationHandler = ( instance ) => {
|
|
697
|
+
return ( request, response, next ) => {
|
|
698
|
+
if ( request.method === "GET" || request.method === "HEAD" || request.method === "OPTIONS" ) {
|
|
699
|
+
next();
|
|
700
|
+
} else {
|
|
701
|
+
const providedOrigin = getRequestOrigin( request );
|
|
702
|
+
// If the browser didn't send Origin/Referer (normal for same-origin form POSTs), let CSRF middleware handle protection instead of blocking here:
|
|
703
|
+
if ( !providedOrigin ) {
|
|
704
|
+
next();
|
|
705
|
+
} else {
|
|
706
|
+
// Accept the origin the server reconstructs from the request, plus any explicitly trusted origins
|
|
707
|
+
// (TI_WEB_TRUSTED_ORIGINS / config.trustedOrigins). The trusted list is needed behind proxies that do
|
|
708
|
+
// not present the external host to the app (e.g. GitHub Codespaces port forwarding), where the browser
|
|
709
|
+
// Origin cannot be reconstructed from the forwarded headers.
|
|
710
|
+
const configured = ( instance && instance.serviceConfig && Array.isArray( instance.serviceConfig.trustedOrigins ) ) ? instance.serviceConfig.trustedOrigins : [];
|
|
711
|
+
const allowedOrigins = [ getBaseUrl( request ) ].concat( configured );
|
|
712
|
+
const normalizedProvided = String( providedOrigin ).trim().toLowerCase();
|
|
713
|
+
const isAllowed = allowedOrigins.some( ( origin ) => String( origin ).trim().toLowerCase() === normalizedProvided );
|
|
714
|
+
if ( isAllowed ) {
|
|
715
|
+
next();
|
|
716
|
+
} else {
|
|
717
|
+
logger.log( `Issue identified with origin/referer mismatch. Received '${ providedOrigin }'; expected one of [ ${ allowedOrigins.join( ", " ) } ].`, logger.logSeverity.WARNING );
|
|
718
|
+
next( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, null, exceptions.httpCode.C_403 ) );
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Ensure a per-session CSRF token and expose it to the client via a non-HTTPOnly cookie (double-submit token).
|
|
727
|
+
* Set only on GET/HEAD to avoid caching/set-cookie noise on API calls.
|
|
728
|
+
*
|
|
729
|
+
* @method
|
|
730
|
+
* @param {TiWebServer} instance
|
|
731
|
+
* @returns {ExpressHandler}
|
|
732
|
+
* @public
|
|
733
|
+
*/
|
|
734
|
+
module.exports.csrfInitHandler = ( instance ) => {
|
|
735
|
+
return ( request, response, next ) => {
|
|
736
|
+
if ( request.method !== "GET" && request.method !== "HEAD" ) {
|
|
737
|
+
next();
|
|
738
|
+
} else {
|
|
739
|
+
try {
|
|
740
|
+
const session = request.session;
|
|
741
|
+
if ( session ) {
|
|
742
|
+
if ( !session.csrfToken ) {
|
|
743
|
+
session.csrfToken = randomBytes( 32 ).toString( "base64url" );
|
|
744
|
+
}
|
|
745
|
+
// Expose the token via a readable cookie for front-end code (double-submit pattern):
|
|
746
|
+
const xfProto = String( request.get( "x-forwarded-proto" ) || "" ).toLowerCase();
|
|
747
|
+
const isSecure = ( request.secure === true ) || ( xfProto === "https" );
|
|
748
|
+
const cookieOptions = {
|
|
749
|
+
path: instance.serviceConfig.cookies.path,
|
|
750
|
+
sameSite: instance.serviceConfig.cookies.sameSite,
|
|
751
|
+
secure: isSecure,
|
|
752
|
+
httpOnly: false
|
|
753
|
+
};
|
|
754
|
+
if ( cookieOptions.sameSite === "none" && !cookieOptions.secure ) {
|
|
755
|
+
logger.log( "CSRF cookie may be blocked: SameSite=None requires Secure; ensure HTTPS or adjust config.", logger.logSeverity.WARNING );
|
|
756
|
+
}
|
|
757
|
+
if ( Number.isFinite( instance.serviceConfig.cookies.maxAge ) ) {
|
|
758
|
+
cookieOptions.maxAge = instance.serviceConfig.cookies.maxAge;
|
|
759
|
+
}
|
|
760
|
+
response.cookie( "ti-xsrf-token", session.csrfToken, cookieOptions );
|
|
761
|
+
}
|
|
762
|
+
next();
|
|
763
|
+
} catch ( error ) {
|
|
764
|
+
next( exceptions.raise( error, null, exceptions.httpCode.C_500 ) );
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Require and validate the CSRF token on state-changing requests.
|
|
772
|
+
* Accept from the header 'X-CSRF-Token' or 'X-XSRF-Token', or body/query 'csrfToken'.
|
|
773
|
+
* Token must match the one in the current session set by {@link csrfInitHandler}.
|
|
774
|
+
*
|
|
775
|
+
* @method
|
|
776
|
+
* @returns {ExpressHandler}
|
|
777
|
+
* @public
|
|
778
|
+
*/
|
|
779
|
+
module.exports.csrfProtectionHandler = () => {
|
|
780
|
+
return ( request, response, next ) => {
|
|
781
|
+
if ( request.method === "GET" || request.method === "HEAD" || request.method === "OPTIONS" ) {
|
|
782
|
+
next();
|
|
783
|
+
} else {
|
|
784
|
+
const expected = request.session && request.session.csrfToken;
|
|
785
|
+
if ( !expected ) {
|
|
786
|
+
next( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_HEADERS, null, exceptions.httpCode.C_403 ) );
|
|
787
|
+
} else {
|
|
788
|
+
// Try to locate the CSRF token in the request:
|
|
789
|
+
const provided =
|
|
790
|
+
request.get( "x-csrf-token" ) ||
|
|
791
|
+
request.get( "x-xsrf-token" ) ||
|
|
792
|
+
( request.body && ( request.body.csrfToken || request.body._csrf ) ) ||
|
|
793
|
+
( request.query && ( request.query.csrfToken || request.query._csrf ) );
|
|
794
|
+
if ( !safeEquals( provided, expected ) ) {
|
|
795
|
+
logger.log( "Issue identified with CSRF token validation fail.", logger.logSeverity.WARNING );
|
|
796
|
+
next( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_HEADERS, null, exceptions.httpCode.C_403 ) );
|
|
797
|
+
} else {
|
|
798
|
+
next();
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
};
|