@ti-engine/web-framework 1.13.1

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