@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
package/bin/web-server.js
CHANGED
|
@@ -1,937 +1,936 @@
|
|
|
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 ServiceConsumer = require( "@ti-engine/core/service-consumer" );
|
|
10
|
-
const exceptions = require( "@ti-engine/core/exceptions" );
|
|
11
|
-
const logger = require( "@ti-engine/core/logger" );
|
|
12
|
-
const { randomBytes } = require( "node:crypto" );
|
|
13
|
-
const path = require( "node:path" );
|
|
14
|
-
const fs = require( "node:fs" );
|
|
15
|
-
const _ = require( "lodash" );
|
|
16
|
-
const express = require( "express" );
|
|
17
|
-
const helmet = require( "helmet" );
|
|
18
|
-
const session = require( "express-session" );
|
|
19
|
-
const cookieParser = require( "cookie-parser" );
|
|
20
|
-
const webHandlers = require( "#web-handlers" );
|
|
21
|
-
const SessionStore = require( "#session-store" );
|
|
22
|
-
const AuthManager = require( "#auth-manager" );
|
|
23
|
-
const authMethod = require( "#auth-manager" ).authMethod;
|
|
24
|
-
const authorization = require( "#authorization" );
|
|
25
|
-
const adminConfigHandlers = require( "#admin-config-handlers" );
|
|
26
|
-
const configService = require( "#config-service" );
|
|
27
|
-
const applyWebConfigEnvOverrides = require( "#web-config-env" );
|
|
28
|
-
|
|
29
|
-
/** @typedef {import("node:http").Server} NodeServer */
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* @typedef {ServiceConfiguration} TiWebServiceConfiguration
|
|
33
|
-
* @property {ApiConfig} api
|
|
34
|
-
* @property {TiWebApplicationConfig} application
|
|
35
|
-
* @property {SettingsAuth} auth
|
|
36
|
-
* @property {SettingsCookies} cookies
|
|
37
|
-
* @property {string} host
|
|
38
|
-
* @property {TiLocalizationLanguage} language
|
|
39
|
-
* @property {number} port
|
|
40
|
-
* @property {string} publicPath
|
|
41
|
-
* @property {number} requestTimeout
|
|
42
|
-
* @property {SettingsStaticCache} staticCache
|
|
43
|
-
* @property {string} tlsCertPath
|
|
44
|
-
* @property {string} tlsKeyPath
|
|
45
|
-
* @property {boolean} useTLS
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* @typedef {Object} TiWebApplicationConfig
|
|
50
|
-
* @property {string} classPath
|
|
51
|
-
*/
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* @typedef {Object} ApiConfig
|
|
55
|
-
* @property {boolean} endpointEnabled
|
|
56
|
-
* @property {ApiInventory} inventory
|
|
57
|
-
* @property {number} requestTimeout
|
|
58
|
-
*/
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* @typedef {Object} SettingsAuth
|
|
62
|
-
* @property {string[]} enabledMethods
|
|
63
|
-
* @property {Object} local
|
|
64
|
-
* @property {Object} oauth2
|
|
65
|
-
* @property {SettingsOAuth2Client} [oauth2.azure]
|
|
66
|
-
* @property {SettingsOAuth2Client} [oauth2.google]
|
|
67
|
-
*/
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* @typedef {Object} SettingsOAuth2Client
|
|
71
|
-
* @property {string} [clientID]
|
|
72
|
-
* @property {string} [clientSecret]
|
|
73
|
-
* @property {string} [callbackUrl]
|
|
74
|
-
* @property {string} [discoveryUrl]
|
|
75
|
-
* @property {boolean} [isPublic]
|
|
76
|
-
* @property {TiTokenEndpointAuthMethod} [tokenEndpointAuthMethod]
|
|
77
|
-
*/
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* @typedef {Object} SettingsStaticCache
|
|
81
|
-
* @property {number} maxAge The `max-age` for `/static` responses, in SECONDS (not a duration string). `0` means every use is revalidated.
|
|
82
|
-
* @property {boolean} immutable Whether to add `immutable`. Only correct when the `/static` filenames are content-addressed.
|
|
83
|
-
* @property {string[]} immutablePaths Path prefixes under `/static` that are served long-lived and `immutable` regardless of the two settings above.
|
|
84
|
-
*/
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* @typedef {Object} SettingsCookies
|
|
88
|
-
* @property {string} secret
|
|
89
|
-
* @property {string} path
|
|
90
|
-
* @property {boolean} httpOnly
|
|
91
|
-
* @property {"lax"|"strict"|"none"} sameSite
|
|
92
|
-
* @property {number} maxAge
|
|
93
|
-
*/
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* @typedef {Record<string, Record<string, ServiceAddress>>} ApiInventory
|
|
97
|
-
*/
|
|
98
|
-
|
|
99
|
-
const webServerConfig = require( "#web-server-config" );
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
this.#
|
|
278
|
-
this.#netServer
|
|
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
|
-
this.#webServer.use( webHandlers.
|
|
318
|
-
this.#webServer.use(
|
|
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
|
-
this
|
|
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
|
-
*
|
|
512
|
-
*
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
*
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
this.#webServer.
|
|
536
|
-
this.#webServer.
|
|
537
|
-
this.#webServer.get( "/
|
|
538
|
-
this.#webServer.get( "/
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
this.#webServer.get( "/admin/config/
|
|
563
|
-
this.#webServer.
|
|
564
|
-
this.#webServer.
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
*
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
this.#unprotectedRoutes.push( "/
|
|
584
|
-
this.#unprotectedRoutes.push( "/
|
|
585
|
-
this.#unprotectedRoutes.push(
|
|
586
|
-
this.#unprotectedRoutes.push(
|
|
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
|
-
|
|
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
|
-
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
* @
|
|
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
|
-
const
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
immutable
|
|
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
|
-
* @
|
|
801
|
-
* @param {string}
|
|
802
|
-
* @param {
|
|
803
|
-
* @
|
|
804
|
-
* @
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
const
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
* @
|
|
831
|
-
* @
|
|
832
|
-
* @
|
|
833
|
-
* @
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
*
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
* @
|
|
851
|
-
* @
|
|
852
|
-
* @param {string}
|
|
853
|
-
* @
|
|
854
|
-
* @
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
* @
|
|
881
|
-
* @param {
|
|
882
|
-
* @param {
|
|
883
|
-
* @
|
|
884
|
-
* @
|
|
885
|
-
*/
|
|
886
|
-
#beginListening( server, port, host ) {
|
|
887
|
-
return new Promise( ( resolve, reject ) => {
|
|
888
|
-
server.once( "error", ( error ) => {
|
|
889
|
-
reject( exceptions.raise( error ) );
|
|
890
|
-
} );
|
|
891
|
-
server.once( "listening", () => {
|
|
892
|
-
resolve( server );
|
|
893
|
-
} );
|
|
894
|
-
server.listen( port, host );
|
|
895
|
-
} );
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
/**
|
|
899
|
-
* Used to stop listening for requests on the specified server.
|
|
900
|
-
*
|
|
901
|
-
* @method
|
|
902
|
-
* @param {NodeServer} server The server instance to stop listening on.
|
|
903
|
-
* @returns {Promise}
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
module.exports.RE_WELL_KNOWN_UNPROTECTED = RE_WELL_KNOWN_UNPROTECTED;
|
|
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 ServiceConsumer = require( "@ti-engine/core/service-consumer" );
|
|
10
|
+
const exceptions = require( "@ti-engine/core/exceptions" );
|
|
11
|
+
const logger = require( "@ti-engine/core/logger" );
|
|
12
|
+
const { randomBytes } = require( "node:crypto" );
|
|
13
|
+
const path = require( "node:path" );
|
|
14
|
+
const fs = require( "node:fs" );
|
|
15
|
+
const _ = require( "lodash" );
|
|
16
|
+
const express = require( "express" );
|
|
17
|
+
const helmet = require( "helmet" );
|
|
18
|
+
const session = require( "express-session" );
|
|
19
|
+
const cookieParser = require( "cookie-parser" );
|
|
20
|
+
const webHandlers = require( "#web-handlers" );
|
|
21
|
+
const SessionStore = require( "#session-store" );
|
|
22
|
+
const AuthManager = require( "#auth-manager" );
|
|
23
|
+
const authMethod = require( "#auth-manager" ).authMethod;
|
|
24
|
+
const authorization = require( "#authorization" );
|
|
25
|
+
const adminConfigHandlers = require( "#admin-config-handlers" );
|
|
26
|
+
const configService = require( "#config-service" );
|
|
27
|
+
const applyWebConfigEnvOverrides = require( "#web-config-env" );
|
|
28
|
+
|
|
29
|
+
/** @typedef {import("node:http").Server} NodeServer */
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {ServiceConfiguration} TiWebServiceConfiguration
|
|
33
|
+
* @property {ApiConfig} api
|
|
34
|
+
* @property {TiWebApplicationConfig} application
|
|
35
|
+
* @property {SettingsAuth} auth
|
|
36
|
+
* @property {SettingsCookies} cookies
|
|
37
|
+
* @property {string} host
|
|
38
|
+
* @property {TiLocalizationLanguage} language
|
|
39
|
+
* @property {number} port
|
|
40
|
+
* @property {string} publicPath
|
|
41
|
+
* @property {number} requestTimeout
|
|
42
|
+
* @property {SettingsStaticCache} staticCache
|
|
43
|
+
* @property {string} tlsCertPath
|
|
44
|
+
* @property {string} tlsKeyPath
|
|
45
|
+
* @property {boolean} useTLS
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {Object} TiWebApplicationConfig
|
|
50
|
+
* @property {string} classPath
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @typedef {Object} ApiConfig
|
|
55
|
+
* @property {boolean} endpointEnabled
|
|
56
|
+
* @property {ApiInventory} inventory
|
|
57
|
+
* @property {number} requestTimeout
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @typedef {Object} SettingsAuth
|
|
62
|
+
* @property {string[]} enabledMethods
|
|
63
|
+
* @property {Object} local
|
|
64
|
+
* @property {Object} oauth2
|
|
65
|
+
* @property {SettingsOAuth2Client} [oauth2.azure]
|
|
66
|
+
* @property {SettingsOAuth2Client} [oauth2.google]
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @typedef {Object} SettingsOAuth2Client
|
|
71
|
+
* @property {string} [clientID]
|
|
72
|
+
* @property {string} [clientSecret]
|
|
73
|
+
* @property {string} [callbackUrl]
|
|
74
|
+
* @property {string} [discoveryUrl]
|
|
75
|
+
* @property {boolean} [isPublic]
|
|
76
|
+
* @property {TiTokenEndpointAuthMethod} [tokenEndpointAuthMethod]
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @typedef {Object} SettingsStaticCache
|
|
81
|
+
* @property {number} maxAge The `max-age` for `/static` responses, in SECONDS (not a duration string). `0` means every use is revalidated.
|
|
82
|
+
* @property {boolean} immutable Whether to add `immutable`. Only correct when the `/static` filenames are content-addressed.
|
|
83
|
+
* @property {string[]} immutablePaths Path prefixes under `/static` that are served long-lived and `immutable` regardless of the two settings above.
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @typedef {Object} SettingsCookies
|
|
88
|
+
* @property {string} secret
|
|
89
|
+
* @property {string} path
|
|
90
|
+
* @property {boolean} httpOnly
|
|
91
|
+
* @property {"lax"|"strict"|"none"} sameSite
|
|
92
|
+
* @property {number} maxAge
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @typedef {Record<string, Record<string, ServiceAddress>>} ApiInventory
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
const webServerConfig = require( "#web-server-config" );
|
|
100
|
+
|
|
101
|
+
/** @import { TiAuthMethod, TiTokenEndpointAuthMethod } from "#auth-manager" */
|
|
102
|
+
/** @import { TiSession } from "#definitions" */
|
|
103
|
+
/** @import User from "#user" */
|
|
104
|
+
/** @import TiWebAppManager from "#web-app-manager" */
|
|
105
|
+
/** @import { ServiceAddress, ServiceConfiguration } from "@ti-engine/core/definitions" */
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Default unprotected static-asset route matchers. The path segments are matched with `(?:[^/]+\/)*` rather than
|
|
109
|
+
* `(?:.+\/)*`: the inner `[^/]+` cannot also consume the "/" delimiter, so the pattern is unambiguous and matches
|
|
110
|
+
* in linear time. The previous `.+` form was ambiguous and backtracked exponentially on hostile request paths such
|
|
111
|
+
* as `/static/a/a/…/a/x` (no trailing extension) — and these matchers run against the raw request path in
|
|
112
|
+
* {@link TiWebServer#isUnprotectedRoute} BEFORE authentication, so that was a pre-auth denial-of-service vector
|
|
113
|
+
* (CodeQL js/redos). The matched language for realistic asset paths is unchanged.
|
|
114
|
+
*
|
|
115
|
+
* @type {RegExp}
|
|
116
|
+
*/
|
|
117
|
+
const RE_STATIC_UNPROTECTED = /^\/static\/(?:[^/]+\/)*[^/]+\.[^/]+$/i;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Default unprotected `/.well-known/` route matcher. See {@link RE_STATIC_UNPROTECTED} for the ReDoS rationale.
|
|
121
|
+
*
|
|
122
|
+
* @type {RegExp}
|
|
123
|
+
*/
|
|
124
|
+
const RE_WELL_KNOWN_UNPROTECTED = /^\/\.well-known\/(?:[^/]+\/)*[^/]+\.[^/]+$/i;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A web server microservice based on the ti-engine.
|
|
128
|
+
* <br/>
|
|
129
|
+
* Note: The web server is fully functional and already comes with all the necessary fundamentals and security features. However, it is designed to be extended
|
|
130
|
+
* with custom logic and functionality to fit your specific needs. Here is a list of methods that you can override to customize the web server behavior:
|
|
131
|
+
* - {@link TiWebServer#defineWebApplicationRoutes} Override this to define custom web application routes. Remember to call the base method if you want to preserve the default behavior as well (recommended).
|
|
132
|
+
* - {@link TiWebServer#defineUnprotectedRoutes} Override this to define unprotected routes. Remember to call the base method if you want to preserve the default behavior as well (recommended).
|
|
133
|
+
* - {@link TiWebServer#verifySession} Override this to implement custom session verification logic.
|
|
134
|
+
*
|
|
135
|
+
* @class TiWebServer
|
|
136
|
+
* @extends ServiceConsumer
|
|
137
|
+
* @public
|
|
138
|
+
*/
|
|
139
|
+
class TiWebServer extends ServiceConsumer {
|
|
140
|
+
|
|
141
|
+
#webServer;
|
|
142
|
+
#netServer;
|
|
143
|
+
#serverUrl = "";
|
|
144
|
+
#isShuttingDown = false;
|
|
145
|
+
#staticContentPaths = [];
|
|
146
|
+
#allowedHosts = [];
|
|
147
|
+
#unprotectedRoutes = [];
|
|
148
|
+
#webAppManager;
|
|
149
|
+
#authManager;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @constructor
|
|
153
|
+
* @param {string} serviceDomainName The service domain name for this service instance.
|
|
154
|
+
* @param {TiWebServiceConfiguration} serviceConfig The JSON configuration for this service. Note that the configuration provided will be merged with the default web server configuration, and it will override any conflicting properties.
|
|
155
|
+
* @throws {TiException.E_GEN_JS_INTERNAL_ERROR} If the web application manager cannot be loaded.
|
|
156
|
+
*/
|
|
157
|
+
constructor( serviceDomainName, serviceConfig ) {
|
|
158
|
+
super( serviceDomainName, applyWebConfigEnvOverrides( _.merge( {}, webServerConfig, ( _.isObjectLike( serviceConfig ) ) ? serviceConfig : {} ) ) );
|
|
159
|
+
|
|
160
|
+
// Include the current host in the list of allowed hosts:
|
|
161
|
+
this.#allowedHosts.push( this.serviceConfig.host );
|
|
162
|
+
|
|
163
|
+
// Add the default and custom public paths to the list of static content:
|
|
164
|
+
this.#staticContentPaths.push( path.join( __dirname, "static" ) );
|
|
165
|
+
let customStaticContentPath = path.normalize( path.isAbsolute( this.serviceConfig.publicPath ) ? this.serviceConfig.publicPath : path.join( process.cwd(), this.serviceConfig.publicPath ) );
|
|
166
|
+
if ( fs.existsSync( customStaticContentPath ) === false ) {
|
|
167
|
+
logger.log( `Public path '${ customStaticContentPath }' does not exist. Static routes will resolve with 404 until path is created.`, logger.logSeverity.WARNING );
|
|
168
|
+
} else {
|
|
169
|
+
this.#staticContentPaths.push( customStaticContentPath );
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this.#authManager = new AuthManager( this.serviceConfig.auth );
|
|
173
|
+
|
|
174
|
+
// If there is a web application configuration, create the web application manager:
|
|
175
|
+
if ( this.serviceConfig.application ) {
|
|
176
|
+
try {
|
|
177
|
+
const webApplicationConstructor = require( path.join( process.cwd(), this.serviceConfig.application.classPath ) );
|
|
178
|
+
this.#webAppManager = new webApplicationConstructor();
|
|
179
|
+
} catch ( error ) {
|
|
180
|
+
logger.log( `Failed to load web application manager from '${ this.serviceConfig.application.classPath }'`, logger.logSeverity.ERROR, error );
|
|
181
|
+
throw exceptions.raise( error );
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* Public interface */
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Property returning the service configuration JSON.
|
|
190
|
+
*
|
|
191
|
+
* @property
|
|
192
|
+
* @returns {TiWebServiceConfiguration}
|
|
193
|
+
* @override
|
|
194
|
+
* @public
|
|
195
|
+
*/
|
|
196
|
+
get serviceConfig() {
|
|
197
|
+
return super.serviceConfig;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Property returning if the web server is currently shutting down.
|
|
202
|
+
*
|
|
203
|
+
* @property
|
|
204
|
+
* @returns {boolean}
|
|
205
|
+
* @public
|
|
206
|
+
*/
|
|
207
|
+
get isShuttingDown() {
|
|
208
|
+
return this.#isShuttingDown;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Property returning the list of static content directories.
|
|
213
|
+
*
|
|
214
|
+
* @property
|
|
215
|
+
* @returns {string[]}
|
|
216
|
+
* @public
|
|
217
|
+
*/
|
|
218
|
+
get staticContentPaths() {
|
|
219
|
+
return this.#staticContentPaths;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Property returning the server URL.
|
|
224
|
+
*
|
|
225
|
+
* @property
|
|
226
|
+
* @returns {string}
|
|
227
|
+
* @public
|
|
228
|
+
*/
|
|
229
|
+
get serverUrl() {
|
|
230
|
+
return this.#serverUrl;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Property returning the {@link TiWebAppManager} instance.
|
|
235
|
+
*
|
|
236
|
+
* @property
|
|
237
|
+
* @returns {TiWebAppManager}
|
|
238
|
+
* @public
|
|
239
|
+
*/
|
|
240
|
+
get webAppManager() {
|
|
241
|
+
return this.#webAppManager;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Starts the web server.
|
|
246
|
+
*
|
|
247
|
+
* @method
|
|
248
|
+
* @returns {Promise}
|
|
249
|
+
* @override
|
|
250
|
+
* @public
|
|
251
|
+
*/
|
|
252
|
+
onStart() {
|
|
253
|
+
return new Promise( ( resolve, reject ) => {
|
|
254
|
+
super.onStart().then( () => {
|
|
255
|
+
// Create and configure the web server:
|
|
256
|
+
this.#webServer = express();
|
|
257
|
+
this.#webServer.set( "trust proxy", true );
|
|
258
|
+
|
|
259
|
+
// Create and configure the net server for HTTPS if enabled in the service config:
|
|
260
|
+
let netServerOptions = {};
|
|
261
|
+
const timeoutCandidates = [
|
|
262
|
+
this.serviceConfig.api.requestTimeout,
|
|
263
|
+
this.serviceConfig.requestTimeout
|
|
264
|
+
].filter( ( value ) => Number.isFinite( value ) );
|
|
265
|
+
const resolvedRequestTimeout = timeoutCandidates.length ? Math.max( ...timeoutCandidates ) : undefined;
|
|
266
|
+
if ( this.serviceConfig.useTLS === true ) {
|
|
267
|
+
if ( !this.serviceConfig.tlsKeyPath || !this.serviceConfig.tlsCertPath ) {
|
|
268
|
+
// Abort initialization if there is something wrong with the TLS key or cert paths:
|
|
269
|
+
return reject( exceptions.raise( exceptions.exceptionCode.E_GEN_INVALID_ARGUMENT_TYPE, {
|
|
270
|
+
tlsKeyPath: this.serviceConfig.tlsKeyPath,
|
|
271
|
+
tlsCertPath: this.serviceConfig.tlsCertPath
|
|
272
|
+
}, exceptions.httpCode.C_500 ) );
|
|
273
|
+
}
|
|
274
|
+
netServerOptions.key = fs.readFileSync( path.join( process.cwd(), this.serviceConfig.tlsKeyPath ) );
|
|
275
|
+
netServerOptions.cert = fs.readFileSync( path.join( process.cwd(), this.serviceConfig.tlsCertPath ) );
|
|
276
|
+
|
|
277
|
+
this.#webServer.use( webHandlers.httpRedirectHandler( this ) );
|
|
278
|
+
this.#netServer = require( "node:https" ).createServer( netServerOptions, this.#webServer );
|
|
279
|
+
} else {
|
|
280
|
+
this.#netServer = require( "node:http" ).createServer( netServerOptions, this.#webServer );
|
|
281
|
+
}
|
|
282
|
+
if ( Number.isFinite( resolvedRequestTimeout ) ) {
|
|
283
|
+
this.#netServer.requestTimeout = resolvedRequestTimeout;
|
|
284
|
+
this.#netServer.headersTimeout = resolvedRequestTimeout + 100;
|
|
285
|
+
if ( typeof this.#netServer.keepAliveTimeout === "number" ) {
|
|
286
|
+
this.#netServer.keepAliveTimeout = resolvedRequestTimeout + 1000;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Set up security and session middlewares first:
|
|
291
|
+
this.#webServer.use( webHandlers.nonceGenerationHandler() );
|
|
292
|
+
// Helmet's built-in Content-Security-Policy is intentionally disabled here because a per-request,
|
|
293
|
+
// nonce-based CSP is enforced on the very next line by webHandlers.cspHeaderHandler() (see
|
|
294
|
+
// components/web-handlers.js) — Helmet's static config cannot express per-response nonces. Every other
|
|
295
|
+
// Helmet header (HSTS, X-Content-Type-Options, X-Frame-Options, …) still applies. This is a deliberate
|
|
296
|
+
// architecture, not missing CSP; do not enable Helmet's static CSP here, as that would drop the nonce.
|
|
297
|
+
// codeql[js/insecure-helmet-configuration]
|
|
298
|
+
this.#webServer.use( helmet( { contentSecurityPolicy: false } ) );
|
|
299
|
+
this.#webServer.use( webHandlers.cspHeaderHandler() );
|
|
300
|
+
this.#webServer.use( express.json( { limit: "1mb" } ) );
|
|
301
|
+
this.#webServer.use( express.urlencoded( { extended: false, limit: "100kb" } ) );
|
|
302
|
+
this.#webServer.use( cookieParser() );
|
|
303
|
+
this.#webServer.use( session( {
|
|
304
|
+
secret: this.serviceConfig.cookies.secret || randomBytes( 32 ).toString( "base64" ),
|
|
305
|
+
resave: false,
|
|
306
|
+
saveUninitialized: false,
|
|
307
|
+
cookie: {
|
|
308
|
+
path: this.serviceConfig.cookies.path,
|
|
309
|
+
httpOnly: this.serviceConfig.cookies.httpOnly,
|
|
310
|
+
secure: "auto",
|
|
311
|
+
sameSite: this.serviceConfig.cookies.sameSite,
|
|
312
|
+
maxAge: this.serviceConfig.cookies.maxAge
|
|
313
|
+
},
|
|
314
|
+
unset: "destroy",
|
|
315
|
+
store: new SessionStore()
|
|
316
|
+
} ) );
|
|
317
|
+
this.#webServer.use( webHandlers.csrfInitHandler( this ) );
|
|
318
|
+
this.#webServer.use( webHandlers.originRefererValidationHandler( this ) );
|
|
319
|
+
this.#webServer.use( webHandlers.csrfProtectionHandler() );
|
|
320
|
+
|
|
321
|
+
// Set up the web server routes:
|
|
322
|
+
this.#webServer.use( webHandlers.onShutDownHandler( this ) );
|
|
323
|
+
this.#webServer.use( webHandlers.resourceProtectionHandler( this ) );
|
|
324
|
+
this.#webServer.use( "/.well-known", express.static( path.join( this.#staticContentPaths[ 0 ], ".well-known" ), { dotfiles: "allow" } ) );
|
|
325
|
+
|
|
326
|
+
// Static content routes are registered in reverse order to ensure that custom assets can override the default ones and be served first:
|
|
327
|
+
const staticCachePolicy = TiWebServer.resolveStaticCachePolicy( this.serviceConfig.staticCache );
|
|
328
|
+
staticCachePolicy.warnings.forEach( ( warning ) => logger.log( warning, logger.logSeverity.WARNING ) );
|
|
329
|
+
_.forEachRight( this.#staticContentPaths, ( staticContentPath ) => {
|
|
330
|
+
// `Cache-Control` is written per file rather than through express.static's `maxAge`/`immutable`
|
|
331
|
+
// options, because the policy is not uniform across the tree (see resolveStaticCachePolicy). A
|
|
332
|
+
// header set here wins: `send` emits its "headers" event BEFORE its own `Cache-Control` block,
|
|
333
|
+
// which then skips a header that is already present. `ETag`/`Last-Modified` are still added by
|
|
334
|
+
// `send`, so the revalidating default costs a conditional request answered with a 304, not a
|
|
335
|
+
// re-download.
|
|
336
|
+
this.#webServer.use( "/static", express.static( staticContentPath, {
|
|
337
|
+
setHeaders: ( response, filePath ) => {
|
|
338
|
+
response.setHeader( "Cache-Control", TiWebServer.staticCacheControlFor( staticContentPath, filePath, staticCachePolicy ) );
|
|
339
|
+
}
|
|
340
|
+
} ) );
|
|
341
|
+
} );
|
|
342
|
+
|
|
343
|
+
// Set up the web application routes:
|
|
344
|
+
this.defineWebApplicationRoutes();
|
|
345
|
+
|
|
346
|
+
// API service proxy route (protected by auth middleware):
|
|
347
|
+
if ( this.serviceConfig.api.endpointEnabled === true ) {
|
|
348
|
+
this.#webServer.post( "/service/:version/:name", webHandlers.serviceCallHandler( this ) );
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Set up error handling middleware:
|
|
352
|
+
this.#webServer.all( "*splat", webHandlers.invalidRouteHandler() );
|
|
353
|
+
this.#webServer.use( webHandlers.defaultErrorHandler() );
|
|
354
|
+
|
|
355
|
+
// Set up the unprotected routes:
|
|
356
|
+
this.defineUnprotectedRoutes();
|
|
357
|
+
|
|
358
|
+
return this.#authManager.initialize();
|
|
359
|
+
} ).then( () => {
|
|
360
|
+
// Hand the web application manager the effective enabled auth methods (after any unconfigured OpenID
|
|
361
|
+
// providers were dropped) so the login page only renders providers a user can actually complete.
|
|
362
|
+
if ( this.#webAppManager && typeof this.#webAppManager.setEnabledAuthMethods === "function" ) {
|
|
363
|
+
this.#webAppManager.setEnabledAuthMethods( this.#authManager.getEnabledMethods() );
|
|
364
|
+
}
|
|
365
|
+
return this.#beginListening( this.#netServer, this.serviceConfig.port, this.serviceConfig.host );
|
|
366
|
+
} ).then( ( server ) => {
|
|
367
|
+
if ( server.listening === true ) {
|
|
368
|
+
this.#serverUrl = `http${ this.serviceConfig.useTLS === true ? "s" : "" }://${ server.address().address }:${ server.address().port }`;
|
|
369
|
+
logger.log( `Web server started at address '${ this.#serverUrl }' within instance '${ ServiceConsumer.instanceID }'.`, logger.logSeverity.NOTICE );
|
|
370
|
+
} else {
|
|
371
|
+
logger.log( `Web server is not listening for requests after startup within instance '${ ServiceConsumer.instanceID }'.`, logger.logSeverity.WARNING );
|
|
372
|
+
}
|
|
373
|
+
resolve();
|
|
374
|
+
} ).catch( ( error ) => {
|
|
375
|
+
logger.log( `Error while trying to start web server within instance '${ ServiceConsumer.instanceID }'!`, logger.logSeverity.ERROR, error );
|
|
376
|
+
reject( exceptions.raise( error ) );
|
|
377
|
+
} );
|
|
378
|
+
} );
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Shuts down the web server.
|
|
383
|
+
*
|
|
384
|
+
* @method
|
|
385
|
+
* @returns {Promise}
|
|
386
|
+
* @override
|
|
387
|
+
* @public
|
|
388
|
+
*/
|
|
389
|
+
onStop() {
|
|
390
|
+
return new Promise( ( resolve, reject ) => {
|
|
391
|
+
this.#isShuttingDown = true;
|
|
392
|
+
|
|
393
|
+
super.onStop().then( () => {
|
|
394
|
+
return this.#endListening( this.#netServer );
|
|
395
|
+
} ).then( () => {
|
|
396
|
+
logger.log( `Web server stopped successfully.`, logger.logSeverity.NOTICE );
|
|
397
|
+
resolve();
|
|
398
|
+
} ).catch( ( error ) => {
|
|
399
|
+
reject( exceptions.raise( error ) );
|
|
400
|
+
} );
|
|
401
|
+
} );
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Used to report health status of the service instance for external monitoring.
|
|
406
|
+
* This is a scheduled job that will be executed at SERVICE_HEALTH_CHECK_INTERVAL time.
|
|
407
|
+
*
|
|
408
|
+
* @method
|
|
409
|
+
* @override
|
|
410
|
+
* @public
|
|
411
|
+
*/
|
|
412
|
+
reportHealthy() {
|
|
413
|
+
super.reportHealthy();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Used to verify the session of a request.
|
|
418
|
+
*
|
|
419
|
+
* @method
|
|
420
|
+
* @param {TiSession} session
|
|
421
|
+
* @returns {boolean}
|
|
422
|
+
* @public
|
|
423
|
+
*/
|
|
424
|
+
verifySession( session ) {
|
|
425
|
+
// TODO: Implement this!
|
|
426
|
+
return Boolean( session && session.user );
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Hook for the application to augment the freshly-authenticated session (e.g. derive domain roles from an
|
|
431
|
+
* identity store or the org chart). Runs synchronously, once per login, before the framework's additive `admin`
|
|
432
|
+
* role is applied. The default is a no-op. Any test-user role injection is an override of whatever the app derives.
|
|
433
|
+
*
|
|
434
|
+
* @method
|
|
435
|
+
* @virtual
|
|
436
|
+
* @param {TiSession} session
|
|
437
|
+
* @param {Object} [request] Optional Express request object that can be used to read body/cookies/query data.
|
|
438
|
+
* @returns {TiSession}
|
|
439
|
+
* @public
|
|
440
|
+
*/
|
|
441
|
+
augmentSession( session, request ) {
|
|
442
|
+
return session;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Used to authenticate a user via the specified auth method.
|
|
447
|
+
*
|
|
448
|
+
* @method
|
|
449
|
+
* @param {TiAuthMethod} authMethod
|
|
450
|
+
* @param {Object} [authDetails={}]
|
|
451
|
+
* @returns {Promise}
|
|
452
|
+
* @public
|
|
453
|
+
*/
|
|
454
|
+
authenticate( authMethod, authDetails = {} ) {
|
|
455
|
+
return this.#authManager.authenticate( authMethod, authDetails );
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Used to set up user authorization according to the specified auth method.
|
|
460
|
+
*
|
|
461
|
+
* @method
|
|
462
|
+
* @param {TiAuthMethod} authMethod
|
|
463
|
+
* @param {URL} currentUrl
|
|
464
|
+
* @param {Object} oidc
|
|
465
|
+
* @returns {Promise<User>}
|
|
466
|
+
* @public
|
|
467
|
+
*/
|
|
468
|
+
authorize( authMethod, currentUrl, oidc ) {
|
|
469
|
+
return this.#authManager.authorize( authMethod, currentUrl, oidc );
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Used to get a service mapping if such exists.
|
|
474
|
+
*
|
|
475
|
+
* @method
|
|
476
|
+
* @param {string} serviceVersion
|
|
477
|
+
* @param {string} serviceName
|
|
478
|
+
* @returns {ServiceAddress}
|
|
479
|
+
* @public
|
|
480
|
+
*/
|
|
481
|
+
getServiceAddress( serviceVersion, serviceName ) {
|
|
482
|
+
let serviceAddress = undefined;
|
|
483
|
+
if ( this.serviceConfig.api && this.serviceConfig.api.inventory ) {
|
|
484
|
+
serviceAddress = ( this.serviceConfig.api.inventory[ serviceVersion ] ) ? this.serviceConfig.api.inventory[ serviceVersion ][ serviceName ] : undefined;
|
|
485
|
+
}
|
|
486
|
+
return serviceAddress;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Used to check if the specified hostname is allowed to access the web server.
|
|
491
|
+
*
|
|
492
|
+
* @method
|
|
493
|
+
* @param {string} hostname
|
|
494
|
+
* @returns {boolean}
|
|
495
|
+
* @public
|
|
496
|
+
*/
|
|
497
|
+
isAllowedHost( hostname ) {
|
|
498
|
+
return this.#allowedHosts.includes( hostname );
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Used to check if the specified route is unprotected (i.e., does not require authentication). The default unprotected routes are:
|
|
503
|
+
* - /
|
|
504
|
+
* - /static/...
|
|
505
|
+
* - /.well-known/...
|
|
506
|
+
* - /not-found
|
|
507
|
+
* - /app
|
|
508
|
+
* - /app/enter
|
|
509
|
+
* - /app/config
|
|
510
|
+
* - /logout
|
|
511
|
+
* - /login/:method
|
|
512
|
+
* <br/>
|
|
513
|
+
* NOTE: You can define custom unprotected routes by overriding the {@link TiWebServer#defineUnprotectedRoutes} method.
|
|
514
|
+
*
|
|
515
|
+
* @method
|
|
516
|
+
* @param {string} route
|
|
517
|
+
* @returns {boolean}
|
|
518
|
+
* @public
|
|
519
|
+
*/
|
|
520
|
+
isUnprotectedRoute( route ) {
|
|
521
|
+
const pathOnly = String( route || "" ).split( "?" )[ 0 ];
|
|
522
|
+
return TiWebServer.isRouteInList( this.#unprotectedRoutes, pathOnly );
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Used to define the web application routes.
|
|
527
|
+
* <br/>
|
|
528
|
+
* NOTE: Override this to define custom web application routes. Remember to call the base method if you want to preserve the default behavior as well.
|
|
529
|
+
*
|
|
530
|
+
* @method
|
|
531
|
+
* @virtual
|
|
532
|
+
* @public
|
|
533
|
+
*/
|
|
534
|
+
defineWebApplicationRoutes() {
|
|
535
|
+
this.#webServer.get( "/", webHandlers.webAppHandler( this ) );
|
|
536
|
+
this.#webServer.get( "/not-found", webHandlers.webAppHandler( this ) );
|
|
537
|
+
this.#webServer.get( "/app", webHandlers.webAppHandler( this ) );
|
|
538
|
+
this.#webServer.get( "/app/:view", webHandlers.webAppHandler( this ) );
|
|
539
|
+
this.#webServer.post( "/app/:service", webHandlers.webAppHandler( this ) );
|
|
540
|
+
this.#webServer.get( "/login/:method", webHandlers.authenticationHandler( this ) );
|
|
541
|
+
this.#webServer.post( "/login/:method", webHandlers.authenticationHandler( this ) );
|
|
542
|
+
this.#webServer.post( "/logout", webHandlers.logoutHandler() );
|
|
543
|
+
this.#webServer.get( "/health", webHandlers.healthHandler() );
|
|
544
|
+
this.#webServer.get( "/me", webHandlers.userInformationHandler() );
|
|
545
|
+
// NOTE: A callback is registered by its path, never by the configured value verbatim — that value is commonly
|
|
546
|
+
// the absolute URL registered with the identity provider, which Express cannot parse as a route pattern.
|
|
547
|
+
[ authMethod.OPENID_GOOGLE, authMethod.OPENID_AZURE ].forEach( ( method ) => {
|
|
548
|
+
if ( this.#authManager.isAuthEnabled( method ) === true ) {
|
|
549
|
+
const callbackPath = this.#authManager.getOAuth2CallbackPath( method );
|
|
550
|
+
if ( callbackPath ) {
|
|
551
|
+
this.#webServer.get( callbackPath, webHandlers.authorizedOAuth2CallbackHandler( this, method ) );
|
|
552
|
+
} else {
|
|
553
|
+
logger.log( `Authentication method '${ method }' is enabled but its callback URL yields no usable route path; its callback endpoint was not registered and sign-in through it will fail.`, logger.logSeverity.WARNING );
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
} );
|
|
557
|
+
|
|
558
|
+
// Admin configuration-management API. Gated by the admin role; these paths are not in the unprotected-routes
|
|
559
|
+
// list, so they also inherit the server's global authentication + CSRF middleware.
|
|
560
|
+
const requireAdmin = authorization.requireAdmin;
|
|
561
|
+
const service = configService.instance;
|
|
562
|
+
this.#webServer.get( "/admin/config/editors", requireAdmin, adminConfigHandlers.listEditors( service ) );
|
|
563
|
+
this.#webServer.get( "/admin/config/editors/:editorKey", requireAdmin, adminConfigHandlers.composeView( service ) );
|
|
564
|
+
this.#webServer.post( "/admin/config/editors/:editorKey", requireAdmin, adminConfigHandlers.saveEditorEdit( service ) );
|
|
565
|
+
this.#webServer.get( "/admin/config/documents/:configKey", requireAdmin, adminConfigHandlers.getCurrent( service ) );
|
|
566
|
+
this.#webServer.get( "/admin/config/documents/:configKey/history", requireAdmin, adminConfigHandlers.getHistory( service ) );
|
|
567
|
+
this.#webServer.get( "/admin/config/changes", requireAdmin, adminConfigHandlers.listChanges( service ) );
|
|
568
|
+
this.#webServer.get( "/admin/config/changes/:changeSetID", requireAdmin, adminConfigHandlers.getChange( service ) );
|
|
569
|
+
this.#webServer.post( "/admin/config/changes/:changeSetID/restore", requireAdmin, adminConfigHandlers.restoreChangeSet( service ) );
|
|
570
|
+
this.#webServer.get( "/admin/config/export", requireAdmin, adminConfigHandlers.exportBundle( service ) );
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Used to define the unprotected routes (i.e., routes that do not require authentication).
|
|
575
|
+
* <br/>
|
|
576
|
+
* NOTE: Override this to define custom unprotected routes. Remember to call the base method if you want to preserve the default behavior as well.
|
|
577
|
+
*
|
|
578
|
+
* @method
|
|
579
|
+
* @virtual
|
|
580
|
+
* @public
|
|
581
|
+
*/
|
|
582
|
+
defineUnprotectedRoutes() {
|
|
583
|
+
this.#unprotectedRoutes.push( "/" );
|
|
584
|
+
this.#unprotectedRoutes.push( "/not-found" );
|
|
585
|
+
this.#unprotectedRoutes.push( "/app" );
|
|
586
|
+
this.#unprotectedRoutes.push( "/app/enter" );
|
|
587
|
+
this.#unprotectedRoutes.push( "/app/config" );
|
|
588
|
+
this.#unprotectedRoutes.push( /^\/login\/[^/]+$/i );
|
|
589
|
+
this.#unprotectedRoutes.push( "/logout" );
|
|
590
|
+
this.#unprotectedRoutes.push( "/health" );
|
|
591
|
+
this.#unprotectedRoutes.push( RE_STATIC_UNPROTECTED );
|
|
592
|
+
this.#unprotectedRoutes.push( RE_WELL_KNOWN_UNPROTECTED );
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Registers a custom application route on the underlying Express app.
|
|
597
|
+
* <br/>
|
|
598
|
+
* NOTE: Call this from a {@link TiWebServer#defineWebApplicationRoutes} override AFTER invoking the base method,
|
|
599
|
+
* so the framework's own routes keep priority and any catch-all route you add resolves last (it will still be
|
|
600
|
+
* registered before the framework's own `*splat` 404 handler). It is only valid once the Express app exists —
|
|
601
|
+
* i.e., from within {@link TiWebServer#defineWebApplicationRoutes}, which {@link TiWebServer#onStart} invokes.
|
|
602
|
+
*
|
|
603
|
+
* @method
|
|
604
|
+
* @param {string} method One of the supported routing verbs: get, post, put, patch, delete, options, head, all.
|
|
605
|
+
* @param {string|RegExp} path The route path or pattern.
|
|
606
|
+
* @param {...Function} handlers One or more Express route handlers/middleware.
|
|
607
|
+
* @returns {TiWebServer} This instance, to allow chaining.
|
|
608
|
+
* @public
|
|
609
|
+
*/
|
|
610
|
+
registerRoute( method, path, ...handlers ) {
|
|
611
|
+
const verb = TiWebServer.normalizeRegistrableMethod( method );
|
|
612
|
+
if ( verb === null ) {
|
|
613
|
+
throw exceptions.raise( exceptions.exceptionCode.E_GEN_INVALID_ARGUMENT_TYPE, { method: method } );
|
|
614
|
+
}
|
|
615
|
+
if ( !this.#webServer ) {
|
|
616
|
+
throw exceptions.raise( exceptions.exceptionCode.E_GEN_NOT_INITIALIZED, { detail: "registerRoute() called before the Express app was created; call it from a defineWebApplicationRoutes() override." } );
|
|
617
|
+
}
|
|
618
|
+
this.#webServer[ verb ]( path, ...handlers );
|
|
619
|
+
return this;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Adds a pattern to the unprotected-routes list — routes that bypass the authentication gate. A string is
|
|
624
|
+
* matched exactly against the request path; a RegExp is tested against it. Consulted at request time by
|
|
625
|
+
* {@link TiWebServer#isUnprotectedRoute}.
|
|
626
|
+
* <br/>
|
|
627
|
+
* NOTE: Call this from a {@link TiWebServer#defineUnprotectedRoutes} override AFTER invoking the base method, to
|
|
628
|
+
* extend (rather than replace) the defaults.
|
|
629
|
+
*
|
|
630
|
+
* @method
|
|
631
|
+
* @param {string|RegExp} pattern The exact path (string) or path matcher (RegExp) to treat as unprotected.
|
|
632
|
+
* @returns {TiWebServer} This instance, to allow chaining.
|
|
633
|
+
* @public
|
|
634
|
+
*/
|
|
635
|
+
addUnprotectedRoute( pattern ) {
|
|
636
|
+
if ( _.isString( pattern ) || _.isRegExp( pattern ) ) {
|
|
637
|
+
this.#unprotectedRoutes.push( pattern );
|
|
638
|
+
} else {
|
|
639
|
+
logger.log( `Ignored an invalid unprotected-route pattern of type '${ typeof pattern }'; expected a string or RegExp.`, logger.logSeverity.WARNING );
|
|
640
|
+
}
|
|
641
|
+
return this;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/* Static interface */
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* The Express routing verbs that {@link TiWebServer#registerRoute} will register. Deliberately limited to
|
|
648
|
+
* route-scoped methods — `use` (global middleware mounting) is intentionally excluded; add a dedicated seam if
|
|
649
|
+
* middleware mounting is ever needed.
|
|
650
|
+
*
|
|
651
|
+
* @type {Set<string>}
|
|
652
|
+
*/
|
|
653
|
+
static #REGISTRABLE_METHODS = new Set( [ "get", "post", "put", "patch", "delete", "options", "head", "all" ] );
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* The default `/static` cache policy: revalidate every use, with a long-lived exception for web fonts.
|
|
657
|
+
* <br/>
|
|
658
|
+
* The default used to be `max-age=1y, immutable`, which was wrong for every consumer that does not hash its asset
|
|
659
|
+
* filenames — and none of them do by default, since the framework's own assets ship under stable names
|
|
660
|
+
* (`/static/scripts/ti-framework.js`, the theme sheets, …). `immutable` promises that the bytes behind THIS URL
|
|
661
|
+
* will never change, and browsers honour it so completely that not even a manual reload revalidates: a deployed
|
|
662
|
+
* CSS or JS fix would simply never reach anyone who had already visited, for up to a year, with no way to tell
|
|
663
|
+
* them otherwise. Revalidating is the only default that is true for a stable filename; `send` still attaches an
|
|
664
|
+
* `ETag`/`Last-Modified`, so the cost is a conditional request answered with a 304, not a re-download.
|
|
665
|
+
* <br/>
|
|
666
|
+
* A consumer that fingerprints its filenames (`app.a1b2c3.css`) makes the promise true and should opt back in via
|
|
667
|
+
* `staticCache: { maxAge: 31536000, immutable: true }`.
|
|
668
|
+
* <br/>
|
|
669
|
+
* NOTE: These defaults deliberately live here rather than in `web-server.json`, because the constructor merges the
|
|
670
|
+
* service config with `_.merge`, which merges arrays BY INDEX — a consumer's `immutablePaths: []` could then never
|
|
671
|
+
* clear a default entry. Absent from the config file, an explicitly empty array means exactly that.
|
|
672
|
+
*
|
|
673
|
+
* @type {Object}
|
|
674
|
+
*/
|
|
675
|
+
static #STATIC_CACHE_DEFAULTS = Object.freeze( {
|
|
676
|
+
maxAge: 0,
|
|
677
|
+
immutable: false,
|
|
678
|
+
// Fonts are the one genuinely content-addressed-in-practice class under `/static`: a released `.woff2` is an
|
|
679
|
+
// artifact, not something that gets edited in place, and its filename already carries the family, weight and
|
|
680
|
+
// style. Configurable, because that is a statement about how a given deployment manages its font files.
|
|
681
|
+
immutablePaths: Object.freeze( [ "/fonts/" ] )
|
|
682
|
+
} );
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* The `max-age` applied to a path matched by `staticCache.immutablePaths`, in seconds (one year — the longest
|
|
686
|
+
* value any cache treats as meaningful, and the conventional pairing for `immutable`).
|
|
687
|
+
*
|
|
688
|
+
* @type {number}
|
|
689
|
+
*/
|
|
690
|
+
static #IMMUTABLE_MAX_AGE = 31536000;
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Normalizes an `immutablePaths` entry to a rooted, slash-terminated prefix (`fonts` -> `/fonts/`), or null when
|
|
694
|
+
* it is not usable. The trailing slash is what keeps `/fonts` from also matching `/fonts-legacy/a.woff2`.
|
|
695
|
+
*
|
|
696
|
+
* @method
|
|
697
|
+
* @static
|
|
698
|
+
* @param {string} entry
|
|
699
|
+
* @returns {string|null}
|
|
700
|
+
*/
|
|
701
|
+
static #normalizeImmutablePath( entry ) {
|
|
702
|
+
if ( typeof entry !== "string" || entry.trim() === "" ) {
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
const trimmed = entry.trim();
|
|
706
|
+
const rooted = trimmed.startsWith( "/" ) ? trimmed : "/" + trimmed;
|
|
707
|
+
return rooted.endsWith( "/" ) ? rooted : rooted + "/";
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Derives the served path of a static file (the part after the `/static` mount, always slash-separated) from the
|
|
712
|
+
* directory it is served out of and its absolute location on disk. A file resolving outside the root yields a
|
|
713
|
+
* `/../`-prefixed path, which matches no normalized prefix and therefore falls back to the default policy.
|
|
714
|
+
*
|
|
715
|
+
* @method
|
|
716
|
+
* @static
|
|
717
|
+
* @param {string} rootPath
|
|
718
|
+
* @param {string} filePath
|
|
719
|
+
* @returns {string}
|
|
720
|
+
*/
|
|
721
|
+
static #toServedPath( rootPath, filePath ) {
|
|
722
|
+
// Split on the platform separator only: on POSIX a backslash is a legal filename character, not a delimiter.
|
|
723
|
+
return "/" + path.relative( String( rootPath || "" ), String( filePath || "" ) ).split( path.sep ).join( "/" );
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Resolves a `staticCache` configuration block into the policy the `/static` mounts apply, filling in
|
|
728
|
+
* {@link TiWebServer.#STATIC_CACHE_DEFAULTS} per key and rejecting values that cannot be honored. Pure: problems
|
|
729
|
+
* are returned as `warnings` rather than logged, so the caller decides how to surface them and a test can assert
|
|
730
|
+
* on them. Static and exposed for unit testing — not part of the customization surface.
|
|
731
|
+
* <br/>
|
|
732
|
+
* `maxAge` is a whole number of SECONDS, mapping 1:1 onto the `Cache-Control` directive — express's `"1y"`-style
|
|
733
|
+
* duration strings are NOT accepted, and are reported rather than silently reinterpreted as milliseconds.
|
|
734
|
+
* <br/>
|
|
735
|
+
* `immutable` is dropped (with a warning) when `maxAge` is 0, because a response that is stale on arrival yet
|
|
736
|
+
* promises never to change is a contradiction. Dropping it fails safe: the misconfiguration costs a revalidation,
|
|
737
|
+
* not a year of unreachable assets.
|
|
738
|
+
*
|
|
739
|
+
* @method
|
|
740
|
+
* @static
|
|
741
|
+
* @param {SettingsStaticCache} [staticCache] The configured block, if any.
|
|
742
|
+
* @returns {{maxAge: number, immutable: boolean, immutablePaths: string[], warnings: string[]}}
|
|
743
|
+
* @public
|
|
744
|
+
*/
|
|
745
|
+
static resolveStaticCachePolicy( staticCache ) {
|
|
746
|
+
const defaults = TiWebServer.#STATIC_CACHE_DEFAULTS;
|
|
747
|
+
const config = _.isObjectLike( staticCache ) ? staticCache : {};
|
|
748
|
+
const warnings = [];
|
|
749
|
+
|
|
750
|
+
let maxAge = defaults.maxAge;
|
|
751
|
+
if ( config.maxAge !== undefined ) {
|
|
752
|
+
if ( Number.isInteger( config.maxAge ) && config.maxAge >= 0 ) {
|
|
753
|
+
maxAge = config.maxAge;
|
|
754
|
+
} else {
|
|
755
|
+
warnings.push( `Ignored an invalid 'staticCache.maxAge' value of '${ config.maxAge }'; it must be a whole, non-negative number of seconds (a duration string such as '1y' is not accepted). Using ${ defaults.maxAge } instead.` );
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
let immutable = defaults.immutable;
|
|
760
|
+
if ( config.immutable !== undefined ) {
|
|
761
|
+
if ( typeof config.immutable === "boolean" ) {
|
|
762
|
+
immutable = config.immutable;
|
|
763
|
+
} else {
|
|
764
|
+
warnings.push( `Ignored a non-boolean 'staticCache.immutable' value of '${ config.immutable }'. Using ${ defaults.immutable } instead.` );
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if ( immutable === true && maxAge === 0 ) {
|
|
768
|
+
warnings.push( `Ignored 'staticCache.immutable' because 'staticCache.maxAge' is 0 — a response that is stale on arrival cannot also promise never to change. Set a positive 'staticCache.maxAge' (and hash your asset filenames) to serve '/static' as immutable.` );
|
|
769
|
+
immutable = false;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
let immutablePaths = defaults.immutablePaths.slice();
|
|
773
|
+
if ( config.immutablePaths !== undefined ) {
|
|
774
|
+
if ( Array.isArray( config.immutablePaths ) ) {
|
|
775
|
+
immutablePaths = [];
|
|
776
|
+
config.immutablePaths.forEach( ( entry ) => {
|
|
777
|
+
const normalized = TiWebServer.#normalizeImmutablePath( entry );
|
|
778
|
+
if ( normalized === null ) {
|
|
779
|
+
warnings.push( `Ignored an invalid 'staticCache.immutablePaths' entry of type '${ typeof entry }'; expected a non-empty path prefix such as '/fonts/'.` );
|
|
780
|
+
} else {
|
|
781
|
+
immutablePaths.push( normalized );
|
|
782
|
+
}
|
|
783
|
+
} );
|
|
784
|
+
} else {
|
|
785
|
+
warnings.push( `Ignored a non-array 'staticCache.immutablePaths' value of type '${ typeof config.immutablePaths }'. Using the default [ ${ defaults.immutablePaths.join( ", " ) } ] instead.` );
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return { maxAge: maxAge, immutable: immutable, immutablePaths: immutablePaths, warnings: warnings };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Builds the `Cache-Control` value for one static file: the long-lived immutable policy when its served path sits
|
|
794
|
+
* under a configured `immutablePaths` prefix (matched case-sensitively, so a case mismatch falls back to the safe
|
|
795
|
+
* side), otherwise the policy's own `maxAge`/`immutable`. A `maxAge` of 0 is emitted as an explicit
|
|
796
|
+
* `must-revalidate` rather than a bare `max-age=0`, matching what the sibling `web-content` package serves.
|
|
797
|
+
* Pure and static; exposed for unit testing — not part of the customization surface.
|
|
798
|
+
*
|
|
799
|
+
* @method
|
|
800
|
+
* @static
|
|
801
|
+
* @param {string} rootPath The directory this `/static` mount serves.
|
|
802
|
+
* @param {string} filePath The absolute path of the file being served.
|
|
803
|
+
* @param {Object} policy A policy as returned by {@link TiWebServer.resolveStaticCachePolicy}.
|
|
804
|
+
* @returns {string}
|
|
805
|
+
* @public
|
|
806
|
+
*/
|
|
807
|
+
static staticCacheControlFor( rootPath, filePath, policy ) {
|
|
808
|
+
const resolved = _.isObjectLike( policy ) ? policy : {};
|
|
809
|
+
const immutablePaths = Array.isArray( resolved.immutablePaths ) ? resolved.immutablePaths : [];
|
|
810
|
+
const servedPath = TiWebServer.#toServedPath( rootPath, filePath );
|
|
811
|
+
|
|
812
|
+
if ( immutablePaths.some( ( prefix ) => servedPath.startsWith( prefix ) ) === true ) {
|
|
813
|
+
return `public, max-age=${ TiWebServer.#IMMUTABLE_MAX_AGE }, immutable`;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const maxAge = ( Number.isInteger( resolved.maxAge ) && resolved.maxAge >= 0 ) ? resolved.maxAge : 0;
|
|
817
|
+
if ( maxAge === 0 ) {
|
|
818
|
+
return "public, max-age=0, must-revalidate";
|
|
819
|
+
}
|
|
820
|
+
return ( resolved.immutable === true ) ? `public, max-age=${ maxAge }, immutable` : `public, max-age=${ maxAge }`;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Normalizes an HTTP method to a lower-case Express routing verb, or returns null if it is not a supported,
|
|
825
|
+
* registrable verb. Anything that is not a string is rejected outright rather than coerced — otherwise a value
|
|
826
|
+
* whose `toString()` happens to yield a verb (`[ "get" ]`, `new String( "get" )`) would register a route and
|
|
827
|
+
* bypass the `E_GEN_INVALID_ARGUMENT_TYPE` that {@link TiWebServer#registerRoute} raises for a bad method.
|
|
828
|
+
* Pure and static; exposed for unit testing — not part of the customization surface.
|
|
829
|
+
*
|
|
830
|
+
* @method
|
|
831
|
+
* @static
|
|
832
|
+
* @param {string} method
|
|
833
|
+
* @returns {string|null}
|
|
834
|
+
* @public
|
|
835
|
+
*/
|
|
836
|
+
static normalizeRegistrableMethod( method ) {
|
|
837
|
+
if ( typeof method !== "string" ) {
|
|
838
|
+
return null;
|
|
839
|
+
}
|
|
840
|
+
const verb = method.trim().toLowerCase();
|
|
841
|
+
return TiWebServer.#REGISTRABLE_METHODS.has( verb ) ? verb : null;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Tests a request path against a list of unprotected-route patterns (string exact-match or RegExp test),
|
|
846
|
+
* returning true on the first match. A RegExp's `lastIndex` is reset defensively so a stateful 'g'/'y' flag
|
|
847
|
+
* cannot cause a match to be skipped. Pure and static; shared by {@link TiWebServer#isUnprotectedRoute} and
|
|
848
|
+
* exposed for unit testing — not part of the customization surface.
|
|
849
|
+
*
|
|
850
|
+
* @method
|
|
851
|
+
* @static
|
|
852
|
+
* @param {Array<string|RegExp>} patterns
|
|
853
|
+
* @param {string} pathOnly The request path with any query string already stripped.
|
|
854
|
+
* @returns {boolean}
|
|
855
|
+
* @public
|
|
856
|
+
*/
|
|
857
|
+
static isRouteInList( patterns, pathOnly ) {
|
|
858
|
+
for ( let idx = 0; idx < patterns.length; idx++ ) {
|
|
859
|
+
const pattern = patterns[ idx ];
|
|
860
|
+
let matched;
|
|
861
|
+
if ( _.isRegExp( pattern ) ) {
|
|
862
|
+
// Avoid stateful RegExp behavior when 'g' or 'y' flags are present:
|
|
863
|
+
pattern.lastIndex = 0;
|
|
864
|
+
matched = pattern.test( pathOnly );
|
|
865
|
+
} else {
|
|
866
|
+
matched = ( pattern === pathOnly );
|
|
867
|
+
}
|
|
868
|
+
if ( matched === true ) {
|
|
869
|
+
return true;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return false;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/* Private interface */
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Used to start listening for requests on the specified port and host and on the specified server.
|
|
879
|
+
*
|
|
880
|
+
* @method
|
|
881
|
+
* @param {NodeServer} server The server instance to listen on.
|
|
882
|
+
* @param {number} port The port to listen on.
|
|
883
|
+
* @param {string} host The host to listen on.
|
|
884
|
+
* @returns {Promise<NodeServer>}
|
|
885
|
+
*/
|
|
886
|
+
#beginListening( server, port, host ) {
|
|
887
|
+
return new Promise( ( resolve, reject ) => {
|
|
888
|
+
server.once( "error", ( error ) => {
|
|
889
|
+
reject( exceptions.raise( error ) );
|
|
890
|
+
} );
|
|
891
|
+
server.once( "listening", () => {
|
|
892
|
+
resolve( server );
|
|
893
|
+
} );
|
|
894
|
+
server.listen( port, host );
|
|
895
|
+
} );
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Used to stop listening for requests on the specified server.
|
|
900
|
+
*
|
|
901
|
+
* @method
|
|
902
|
+
* @param {NodeServer} server The server instance to stop listening on.
|
|
903
|
+
* @returns {Promise}
|
|
904
|
+
*/
|
|
905
|
+
#endListening( server ) {
|
|
906
|
+
return new Promise( ( resolve, reject ) => {
|
|
907
|
+
if ( !server ) {
|
|
908
|
+
resolve();
|
|
909
|
+
} else {
|
|
910
|
+
// Close all connections after a short delay to allow all requests to complete:
|
|
911
|
+
setTimeout( () => {
|
|
912
|
+
if ( typeof server.closeIdleConnections === "function" ) {
|
|
913
|
+
server.closeIdleConnections();
|
|
914
|
+
}
|
|
915
|
+
if ( typeof server.closeAllConnections === "function" ) {
|
|
916
|
+
server.closeAllConnections();
|
|
917
|
+
}
|
|
918
|
+
}, 1000 );
|
|
919
|
+
|
|
920
|
+
server.close( ( error ) => {
|
|
921
|
+
if ( error ) {
|
|
922
|
+
reject( exceptions.raise( error ) );
|
|
923
|
+
} else {
|
|
924
|
+
resolve();
|
|
925
|
+
}
|
|
926
|
+
} );
|
|
927
|
+
}
|
|
928
|
+
} );
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
module.exports = TiWebServer;
|
|
934
|
+
// Exported for unit testing of the ReDoS-hardened matchers; not part of the customization surface.
|
|
935
|
+
TiWebServer.RE_STATIC_UNPROTECTED = RE_STATIC_UNPROTECTED;
|
|
936
|
+
TiWebServer.RE_WELL_KNOWN_UNPROTECTED = RE_WELL_KNOWN_UNPROTECTED;
|