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