@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.
- package/.env +4 -0
- package/CHANGELOG.md +274 -0
- package/README.md +26 -0
- package/bin/build/post-install.js +18 -0
- package/bin/localization/web-server-labels.json +28 -0
- package/bin/static/.well-known/appspecific/com.chrome.devtools.json +6 -0
- package/bin/static/favicon.ico +0 -0
- package/bin/static/fragments/components/component-notification-bar.html +21 -0
- package/bin/static/fragments/components/component-sidebar-flyout.html +37 -0
- package/bin/static/fragments/components/component-sidebar.html +33 -0
- package/bin/static/fragments/components/component-tooltip.html +11 -0
- package/bin/static/fragments/components/component-topbar.html +6 -0
- package/bin/static/fragments/frame-administration.html +3 -0
- package/bin/static/fragments/frame-application.html +19 -0
- package/bin/static/fragments/frame-dashboard.html +3 -0
- package/bin/static/fragments/frame-login.html +105 -0
- package/bin/static/fragments/frame-not-found.html +3 -0
- package/bin/static/fragments/frame-profile.html +3 -0
- package/bin/static/index.html +23 -0
- package/bin/static/scripts/lib/alpinejs-csp.min.js +7 -0
- package/bin/static/scripts/lib/htmx.min.js +1 -0
- package/bin/static/scripts/lib/safe-nonce.min.js +1 -0
- package/bin/static/scripts/ti-charts.js +1591 -0
- package/bin/static/scripts/ti-framework.css +3195 -0
- package/bin/static/scripts/ti-framework.js +1427 -0
- package/bin/static/scripts/ti-theme-black-glass.css +216 -0
- package/bin/static/scripts/ti-theme-daylight.css +87 -0
- package/bin/web-app-manager.js +564 -0
- package/bin/web-server.js +604 -0
- package/bin/web-server.json +49 -0
- package/components/admin-config-handlers.js +92 -0
- package/components/auth-manager.js +344 -0
- package/components/authorization.js +135 -0
- package/components/config-change-notifier.js +98 -0
- package/components/config-registry.js +246 -0
- package/components/config-service.js +349 -0
- package/components/config-store.js +246 -0
- package/components/definitions.types.js +26 -0
- package/components/session-store.js +111 -0
- package/components/user.js +133 -0
- package/components/web-handlers.js +765 -0
- package/package.json +66 -0
|
@@ -0,0 +1,564 @@
|
|
|
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 exceptions = require( "@ti-engine/core/exceptions" );
|
|
10
|
+
const tools = require( "@ti-engine/core/tools" );
|
|
11
|
+
const localization = require( "@ti-engine/core/localization" );
|
|
12
|
+
const path = require( "node:path" );
|
|
13
|
+
const fs = require( "node:fs" );
|
|
14
|
+
const configRegistry = require( "#config-registry" );
|
|
15
|
+
const configService = require( "#config-service" );
|
|
16
|
+
const authorization = require( "#authorization" );
|
|
17
|
+
|
|
18
|
+
const RE_NONCE_ATTR = /\{ti-nonce-placeholder}/g;
|
|
19
|
+
const RE_CSRF_ATTR = /\{ti-csrf-placeholder}/g;
|
|
20
|
+
const RE_HTMX_CONFIG = /\{ti-htmx-config-placeholder}/g;
|
|
21
|
+
const RE_CSP_NONCE = /^[A-Za-z0-9+/=_-]{16,}$/;
|
|
22
|
+
const TI_NESTED_FRAME_PLACEHOLDER = "ti-nested-frame-placeholder";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A generic web application manager that handles the rendering and behavior of web application views. It is designed to be extended by specific web application
|
|
26
|
+
* managers for each web application you want to implement with the ti-engine web framework.
|
|
27
|
+
* <br/>
|
|
28
|
+
* NOTE: You should not instantiate this class directly. Instead, extend it and override the abstract methods as needed. Additionally, you should configure your
|
|
29
|
+
* ti-engine web server 'TiWebApplicationConfig' settings by specifying the 'classPath' that corresponds to your web application manager. The path should be
|
|
30
|
+
* relative to the intended process's working directory.
|
|
31
|
+
*
|
|
32
|
+
* @class TiWebAppManager
|
|
33
|
+
* @abstract
|
|
34
|
+
* @public
|
|
35
|
+
*/
|
|
36
|
+
class TiWebAppManager {
|
|
37
|
+
|
|
38
|
+
#webAppIdentifier;
|
|
39
|
+
#fragments = {};
|
|
40
|
+
#staticFileCache = {};
|
|
41
|
+
#staticFileCacheEnabled;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @constructor
|
|
45
|
+
* @param {string} identifier The identifier for this web application. Should be unique and recognizable.
|
|
46
|
+
* @throws {TiException.E_GEN_ABSTRACT_CLASS_INIT} If this class is instantiated directly.
|
|
47
|
+
*/
|
|
48
|
+
constructor( identifier ) {
|
|
49
|
+
// Make sure this abstract class cannot be instantiated:
|
|
50
|
+
if ( new.target === TiWebAppManager ) {
|
|
51
|
+
throw exceptions.raise( exceptions.exceptionCode.E_GEN_ABSTRACT_CLASS_INIT, { name: this.constructor.name } );
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.#webAppIdentifier = identifier;
|
|
55
|
+
this.#staticFileCacheEnabled = ( process.env.TI_WEB_APP_STATIC_CACHE_DISABLED !== "true" );
|
|
56
|
+
|
|
57
|
+
// Define the default HTML fragments for the application:
|
|
58
|
+
this.#fragments[ 'home' ] = {
|
|
59
|
+
path: "index.html",
|
|
60
|
+
components: [ "component-notification-bar" ]
|
|
61
|
+
};
|
|
62
|
+
this.#fragments[ 'application-main' ] = {
|
|
63
|
+
title: "Application",
|
|
64
|
+
path: "fragments/frame-application.html",
|
|
65
|
+
components: [ "component-topbar", "component-sidebar", "component-notification-bar", "component-sidebar-flyout" ]
|
|
66
|
+
};
|
|
67
|
+
this.#fragments[ 'login' ] = {
|
|
68
|
+
title: "Login",
|
|
69
|
+
path: "fragments/frame-login.html"
|
|
70
|
+
};
|
|
71
|
+
this.#fragments[ 'dashboard' ] = {
|
|
72
|
+
title: "Dashboard",
|
|
73
|
+
path: "fragments/frame-dashboard.html"
|
|
74
|
+
};
|
|
75
|
+
this.#fragments[ 'administration' ] = {
|
|
76
|
+
title: "Administration",
|
|
77
|
+
path: "fragments/frame-administration.html"
|
|
78
|
+
};
|
|
79
|
+
this.#fragments[ 'profile' ] = {
|
|
80
|
+
title: "Profile",
|
|
81
|
+
path: "fragments/frame-profile.html"
|
|
82
|
+
};
|
|
83
|
+
this.#fragments[ 'not-found' ] = {
|
|
84
|
+
title: "Not Found",
|
|
85
|
+
path: "fragments/frame-not-found.html"
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* Public interface */
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Returns the identifier for this web application.
|
|
93
|
+
*
|
|
94
|
+
* @property
|
|
95
|
+
* @returns {string}
|
|
96
|
+
* @public
|
|
97
|
+
*/
|
|
98
|
+
get webAppIdentifier() {
|
|
99
|
+
return this.#webAppIdentifier;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Adds a new HTML fragment to the web application.
|
|
104
|
+
* <br/>
|
|
105
|
+
* NOTE: This method should only be called during the initialization phase of your web application manager.
|
|
106
|
+
*
|
|
107
|
+
* @method
|
|
108
|
+
* @param {string} identifier
|
|
109
|
+
* @param {Object} fragment The fragment descriptor (`{ title, path, components }`). May also carry an optional
|
|
110
|
+
* `roles` array (`Array<string|number>`): when present, the default {@link TiWebAppManager#verifyAccess} serves
|
|
111
|
+
* the fragment only to sessions holding at least one of those roles; omit it (or leave empty) for a public screen.
|
|
112
|
+
* @throws {TiException.E_GEN_UNALLOWED_OVERRIDE} If a fragment with the same identifier already exists.
|
|
113
|
+
* @public
|
|
114
|
+
*/
|
|
115
|
+
addFragment( identifier, fragment ) {
|
|
116
|
+
if ( this.#fragments[ identifier ] === undefined ) {
|
|
117
|
+
this.#fragments[ identifier ] = fragment;
|
|
118
|
+
} else {
|
|
119
|
+
throw exceptions.raise( exceptions.exceptionCode.E_GEN_UNALLOWED_OVERRIDE, { identifier: identifier } );
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Registers an editable configuration document with the framework config registry (JSON Schema + semantic
|
|
125
|
+
* validators + default value + editor metadata). Call during initialization. See {@link ConfigRegistry#register}.
|
|
126
|
+
*
|
|
127
|
+
* @method
|
|
128
|
+
* @param {string} configKey
|
|
129
|
+
* @param {Object} definition
|
|
130
|
+
* @returns {TiWebAppManager} this (chainable)
|
|
131
|
+
* @public
|
|
132
|
+
*/
|
|
133
|
+
registerConfigDocument( configKey, definition ) {
|
|
134
|
+
configRegistry.instance.register( configKey, definition );
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Registers a JSON Schema that is referenced (via `$ref`) by config-document schemas but is not itself a document.
|
|
140
|
+
*
|
|
141
|
+
* @method
|
|
142
|
+
* @param {Object} schema
|
|
143
|
+
* @returns {TiWebAppManager} this (chainable)
|
|
144
|
+
* @public
|
|
145
|
+
*/
|
|
146
|
+
registerConfigSchema( schema ) {
|
|
147
|
+
configRegistry.instance.addSchema( schema );
|
|
148
|
+
return this;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Registers a composite (entity) editor with the framework config service — a `compose(docs)`/`decompose(edited,docs)`
|
|
153
|
+
* pair over one or more documents. Call during initialization. See {@link ConfigService#registerEditor}.
|
|
154
|
+
*
|
|
155
|
+
* @method
|
|
156
|
+
* @param {string} editorKey
|
|
157
|
+
* @param {Object} definition
|
|
158
|
+
* @returns {TiWebAppManager} this (chainable)
|
|
159
|
+
* @public
|
|
160
|
+
*/
|
|
161
|
+
registerConfigEditor( editorKey, definition ) {
|
|
162
|
+
configService.instance.registerEditor( editorKey, definition );
|
|
163
|
+
return this;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Used to clear the static file cache. This is useful for testing purposes to ensure that the web server is always serving fresh content.
|
|
168
|
+
*
|
|
169
|
+
* @method
|
|
170
|
+
* @public
|
|
171
|
+
*/
|
|
172
|
+
clearStaticFileCache() {
|
|
173
|
+
this.#staticFileCache = {};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Optional HTML transformation hook.
|
|
178
|
+
* <br/>
|
|
179
|
+
* NOTE: Override in subclasses to add nonces or other dynamic data to outgoing HTML.
|
|
180
|
+
*
|
|
181
|
+
* @method
|
|
182
|
+
* @param {string} html
|
|
183
|
+
* @param {Object} [options]
|
|
184
|
+
* @param {string} [options.csrfToken] Optional CSRF token to inject into the HTML.
|
|
185
|
+
* @param {boolean} [options.isHome] Optional flag to indicate whether the requested route is the home page.
|
|
186
|
+
* @param {string} [options.nonce] Optional CSP nonce to inject into inline scripts/styles.
|
|
187
|
+
* @param {string} [options.title] Optional title to replace the placeholder in the HTML.
|
|
188
|
+
* @returns {Promise<string>}
|
|
189
|
+
* @virtual
|
|
190
|
+
* @public
|
|
191
|
+
*/
|
|
192
|
+
transformHtml( html, options = {} ) {
|
|
193
|
+
return new Promise( ( resolve, reject ) => {
|
|
194
|
+
let transformedHtml = String( html );
|
|
195
|
+
|
|
196
|
+
// Insert nonce in all placeholder locations. If nonce is not provided or is invalid, this will use an empty string instead to remove the placeholder:
|
|
197
|
+
const nonce = ( typeof options?.nonce === "string" && RE_CSP_NONCE.test( options?.nonce ) ) ? options?.nonce : "";
|
|
198
|
+
transformedHtml = transformedHtml.replaceAll( RE_NONCE_ATTR, nonce );
|
|
199
|
+
if ( options.isHome ) {
|
|
200
|
+
let htmxConfig = {
|
|
201
|
+
inlineScriptNonce: nonce,
|
|
202
|
+
inlineStyleNonce: nonce,
|
|
203
|
+
allowEval: false,
|
|
204
|
+
refreshOnHistoryMiss: true,
|
|
205
|
+
historyCacheSize: 0
|
|
206
|
+
};
|
|
207
|
+
transformedHtml = transformedHtml.replace( RE_HTMX_CONFIG, JSON.stringify( htmxConfig ) );
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const csrfToken = ( typeof options?.csrfToken === "string" ) ? options?.csrfToken : "";
|
|
211
|
+
transformedHtml = transformedHtml.replaceAll( RE_CSRF_ATTR, csrfToken );
|
|
212
|
+
|
|
213
|
+
transformedHtml = transformedHtml.replace( "{ti-title-placeholder}", options.title || "" );
|
|
214
|
+
|
|
215
|
+
resolve( transformedHtml );
|
|
216
|
+
} );
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Used to assemble the complete HTML view for the requested route, including nested HTML fragments.
|
|
221
|
+
*
|
|
222
|
+
* @method
|
|
223
|
+
* @param {TiSession} session
|
|
224
|
+
* @param {string[]} staticContentPaths
|
|
225
|
+
* @param {string} route
|
|
226
|
+
* @param {Object} [options]
|
|
227
|
+
* @param {string} [options.csrfToken] Optional CSRF token to inject into the HTML.
|
|
228
|
+
* @param {boolean} [options.isPartial] Optional flag to indicate whether the requested route is a partial load of a fragment.
|
|
229
|
+
* @param {string} [options.view] Optional view name to load within this route.
|
|
230
|
+
* @param {string} [options.nonce] Optional CSP nonce to inject into inline scripts/styles.
|
|
231
|
+
* @returns {Promise<string>}
|
|
232
|
+
* @public
|
|
233
|
+
*/
|
|
234
|
+
assembleHtmlView( session, staticContentPaths, route, options = {} ) {
|
|
235
|
+
return new Promise( ( resolve, reject ) => {
|
|
236
|
+
let fragment;
|
|
237
|
+
let getHtmlPromises = [];
|
|
238
|
+
let localOptions = ( options && typeof options === "object" ) ? { ...options } : {};
|
|
239
|
+
|
|
240
|
+
if ( route === "/" ) {
|
|
241
|
+
fragment = this.#fragments[ 'home' ];
|
|
242
|
+
getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, fragment, { ...localOptions, isHome: true } ) );
|
|
243
|
+
} else if ( route === "/app/error" ) {
|
|
244
|
+
// TODO: This endpoint is for testing purposes only. Remove later.
|
|
245
|
+
return reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_METHOD ) );
|
|
246
|
+
} else if ( route === "/app" || route === "/app/enter" ) {
|
|
247
|
+
fragment = ( session && session.user ) ? this.#fragments[ 'application-main' ] : this.#fragments[ 'login' ];
|
|
248
|
+
getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, fragment, localOptions ) );
|
|
249
|
+
} else if ( route === "/not-found" ) {
|
|
250
|
+
getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, this.#fragments[ 'home' ], { ...localOptions, isHome: true, title: this.#fragments[ 'not-found' ].title } ) );
|
|
251
|
+
getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, this.#fragments[ 'not-found' ], localOptions ) );
|
|
252
|
+
} else {
|
|
253
|
+
fragment = this.#fragments[ options.view ];
|
|
254
|
+
if ( !fragment ) {
|
|
255
|
+
// Abort execution if the requested view is not found:
|
|
256
|
+
return reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI ) );
|
|
257
|
+
} else {
|
|
258
|
+
// This handles application refreshes from nested frames:
|
|
259
|
+
if ( options.isPartial !== true ) {
|
|
260
|
+
getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, this.#fragments[ 'home' ], {
|
|
261
|
+
...localOptions,
|
|
262
|
+
isHome: true,
|
|
263
|
+
title: fragment.title
|
|
264
|
+
} ) );
|
|
265
|
+
getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, this.#fragments[ 'application-main' ], localOptions ) );
|
|
266
|
+
}
|
|
267
|
+
getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, fragment, localOptions ) );
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
Promise.all( getHtmlPromises ).then( ( filesData ) => {
|
|
272
|
+
let assembledHtml = undefined;
|
|
273
|
+
filesData.forEach( ( fileData ) => {
|
|
274
|
+
// There should always be at most one ti-nested-frame-placeholder element in each HTML fragment:
|
|
275
|
+
assembledHtml = ( assembledHtml ) ? this.#replacePlaceholderElement( assembledHtml, TI_NESTED_FRAME_PLACEHOLDER, fileData ) : fileData;
|
|
276
|
+
} );
|
|
277
|
+
resolve( assembledHtml );
|
|
278
|
+
} ).catch( ( error ) => {
|
|
279
|
+
reject( exceptions.raise( error ) );
|
|
280
|
+
} );
|
|
281
|
+
} );
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Used to process a request for a data resource.
|
|
286
|
+
*
|
|
287
|
+
* @method
|
|
288
|
+
* @param {TiSession} session
|
|
289
|
+
* @param {string} view
|
|
290
|
+
* @param {Object} [options]
|
|
291
|
+
* @returns {Promise<Object>}
|
|
292
|
+
* @virtual
|
|
293
|
+
* @public
|
|
294
|
+
*/
|
|
295
|
+
processDataRequest( session, view, options = {} ) {
|
|
296
|
+
return new Promise( ( resolve, reject ) => {
|
|
297
|
+
if ( view === "config" ) {
|
|
298
|
+
resolve( {
|
|
299
|
+
labels: localization.getAllLabels( session?.language ),
|
|
300
|
+
auth: {
|
|
301
|
+
isAuthenticated: Boolean( session && session.user )
|
|
302
|
+
}
|
|
303
|
+
} );
|
|
304
|
+
} else {
|
|
305
|
+
reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI, { view: view } ) );
|
|
306
|
+
}
|
|
307
|
+
} );
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Used to process an application service request.
|
|
312
|
+
*
|
|
313
|
+
* @method
|
|
314
|
+
* @param {TiSession} session
|
|
315
|
+
* @param {string} service
|
|
316
|
+
* @param {Object} params
|
|
317
|
+
* @returns {Promise<Object>}
|
|
318
|
+
* @virtual
|
|
319
|
+
* @public
|
|
320
|
+
*/
|
|
321
|
+
processServiceRequest( session, service, params ) {
|
|
322
|
+
return new Promise( ( resolve, reject ) => {
|
|
323
|
+
reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI, { service: service } ) );
|
|
324
|
+
} );
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Used to verify whether the current user has access to the requested resource. The default implementation gates
|
|
329
|
+
* HTML fragments by their declared `roles`: a fragment registered via {@link TiWebAppManager#addFragment} with a
|
|
330
|
+
* `roles` array is served only to sessions holding at least one of those roles (see {@link addFragment}); a
|
|
331
|
+
* fragment with no `roles` is public to any authenticated user. This makes role-restricted screens unreachable by
|
|
332
|
+
* direct URL, not merely hidden in the UI. Override in subclasses only to implement additional/alternative checks.
|
|
333
|
+
*
|
|
334
|
+
* @method
|
|
335
|
+
* @virtual
|
|
336
|
+
* @param {TiSession} session
|
|
337
|
+
* @param {Object} resource The fragment descriptor; its optional `resource.roles` lists the roles permitted to load it.
|
|
338
|
+
* @returns {Promise}
|
|
339
|
+
* @exception {TiException.E_SEC_UNAUTHORIZED_ACCESS} (403) When the session holds none of the fragment's required roles.
|
|
340
|
+
* @public
|
|
341
|
+
*/
|
|
342
|
+
verifyAccess( session, resource ) {
|
|
343
|
+
return new Promise( ( resolve, reject ) => {
|
|
344
|
+
const requiredRoles = ( resource && resource.roles ) ? resource.roles : null;
|
|
345
|
+
const userRoles = ( session && session.user && session.user.roles ) || [];
|
|
346
|
+
if ( authorization.isAccessAllowed( requiredRoles, userRoles ) ) {
|
|
347
|
+
return resolve();
|
|
348
|
+
}
|
|
349
|
+
reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_403 ) );
|
|
350
|
+
} );
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* Private interface */
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Returns the HTML fragment for the requested route.
|
|
357
|
+
*
|
|
358
|
+
* @method
|
|
359
|
+
* @param {TiSession} session
|
|
360
|
+
* @param {string[]} staticContentPaths
|
|
361
|
+
* @param {Object} fragment
|
|
362
|
+
* @param {Object} [options]
|
|
363
|
+
* @param {string} [options.csrfToken] Optional CSRF token to inject into the HTML.
|
|
364
|
+
* @param {boolean} [options.isHome] Optional flag to indicate whether the requested route is the home page.
|
|
365
|
+
* @param {string} [options.nonce] Optional CSP nonce to inject into inline scripts/styles.
|
|
366
|
+
* @returns {Promise<string>}
|
|
367
|
+
* @private
|
|
368
|
+
*/
|
|
369
|
+
#getHtmlFragment( session, staticContentPaths, fragment, options = {} ) {
|
|
370
|
+
return new Promise( ( resolve, reject ) => {
|
|
371
|
+
this.verifyAccess( session, fragment ).then( () => {
|
|
372
|
+
return this.#locateStaticFile( staticContentPaths, fragment.path );
|
|
373
|
+
} ).then( ( fileData ) => {
|
|
374
|
+
return this.#replaceComponentPlaceholders( fileData, staticContentPaths, fragment.components );
|
|
375
|
+
} ).then( ( fileData ) => {
|
|
376
|
+
return this.transformHtml( fileData, { ...options, title: fragment.title || options.title } );
|
|
377
|
+
} ).then( ( fileData ) => {
|
|
378
|
+
resolve( fileData );
|
|
379
|
+
} ).catch( ( error ) => {
|
|
380
|
+
reject( exceptions.raise( error ) );
|
|
381
|
+
} );
|
|
382
|
+
} );
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Attempts to locate the requested static file in the provided static content paths.
|
|
387
|
+
*
|
|
388
|
+
* @method
|
|
389
|
+
* @param {string[]} staticContentPaths A list of directories to search for static content. If sent as expected by the web server, the first item in the array should be the system default path.
|
|
390
|
+
* @param {string} filePath Relative path to the static file to locate.
|
|
391
|
+
* @returns {Promise<string>}
|
|
392
|
+
* @private
|
|
393
|
+
*/
|
|
394
|
+
#locateStaticFile( staticContentPaths, filePath ) {
|
|
395
|
+
return new Promise( ( resolve, reject ) => {
|
|
396
|
+
if ( this.#staticFileCacheEnabled === true && this.#staticFileCache[ filePath ] !== undefined ) {
|
|
397
|
+
resolve( this.#staticFileCache[ filePath ] );
|
|
398
|
+
} else {
|
|
399
|
+
let fullFilePath;
|
|
400
|
+
// Search for the file in the static content paths in reverse order so that the default system path is checked last. This will ensure that
|
|
401
|
+
// any fragment overrides are loaded first (i.e., fragments with the same relative path):
|
|
402
|
+
for ( let idx = staticContentPaths.length - 1; idx >= 0; idx-- ) {
|
|
403
|
+
const staticContentPath = staticContentPaths[ idx ];
|
|
404
|
+
let potentialFilePath = path.join( staticContentPath, filePath );
|
|
405
|
+
if ( fs.existsSync( potentialFilePath ) ) {
|
|
406
|
+
fullFilePath = potentialFilePath;
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if ( fullFilePath !== undefined ) {
|
|
412
|
+
fs.promises.readFile( fullFilePath, "utf8" ).then( ( fileData ) => {
|
|
413
|
+
if ( this.#staticFileCacheEnabled === true ) {
|
|
414
|
+
this.#staticFileCache[ filePath ] = fileData;
|
|
415
|
+
}
|
|
416
|
+
resolve( fileData );
|
|
417
|
+
} ).catch( ( error ) => {
|
|
418
|
+
reject( exceptions.raise( error ) );
|
|
419
|
+
} );
|
|
420
|
+
} else {
|
|
421
|
+
reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI ) );
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
} );
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Used to replace the component placeholders in the HTML with the actual component HTML.
|
|
429
|
+
*
|
|
430
|
+
* @method
|
|
431
|
+
* @param {string} html
|
|
432
|
+
* @param {string[]} staticContentPaths
|
|
433
|
+
* @param {Array<string>} components
|
|
434
|
+
* @returns {Promise<string>}
|
|
435
|
+
* @private
|
|
436
|
+
*/
|
|
437
|
+
#replaceComponentPlaceholders( html, staticContentPaths, components ) {
|
|
438
|
+
return new Promise( ( resolve, reject ) => {
|
|
439
|
+
if ( components === undefined || components.length === 0 ) {
|
|
440
|
+
resolve( html );
|
|
441
|
+
} else {
|
|
442
|
+
let transformedHtml = String( html );
|
|
443
|
+
let promises = [];
|
|
444
|
+
const componentData = {};
|
|
445
|
+
tools.arrayUniques( components ).forEach( ( component ) => {
|
|
446
|
+
promises.push( this.#locateStaticFile( staticContentPaths, `fragments/components/${ component }.html` ).then( ( fileData ) => {
|
|
447
|
+
componentData[ component ] = fileData;
|
|
448
|
+
} ) );
|
|
449
|
+
} );
|
|
450
|
+
|
|
451
|
+
Promise.all( promises ).then( () => {
|
|
452
|
+
components.forEach( ( component ) => {
|
|
453
|
+
transformedHtml = this.#replacePlaceholderElement( transformedHtml, `ti-${ component }-placeholder`, componentData[ component ] );
|
|
454
|
+
} );
|
|
455
|
+
resolve( transformedHtml );
|
|
456
|
+
} ).catch( ( error ) => {
|
|
457
|
+
reject( exceptions.raise( error ) );
|
|
458
|
+
} );
|
|
459
|
+
}
|
|
460
|
+
} );
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Used to replace a placeholder element in the HTML with the provided replacement.
|
|
465
|
+
* <br/>
|
|
466
|
+
* The placeholder's attributes are exposed to the replacement HTML as `{ti-<attr-name>}` tokens, allowing the
|
|
467
|
+
* component template to consume initial data without changing its `x-data` factory.
|
|
468
|
+
* <br/>
|
|
469
|
+
* If the replacement HTML contains a `<ti-slot></ti-slot>` (or self-closing `<ti-slot/>`) marker, the placeholder's
|
|
470
|
+
* inner content replaces it precisely; otherwise, inner content is appended before the replacement's last closing
|
|
471
|
+
* tag (legacy behaviour) so existing components keep working.
|
|
472
|
+
*
|
|
473
|
+
* @method
|
|
474
|
+
* @param {string} html
|
|
475
|
+
* @param {string} tagName
|
|
476
|
+
* @param {string} replacement
|
|
477
|
+
* @returns {string}
|
|
478
|
+
* @private
|
|
479
|
+
*/
|
|
480
|
+
#replacePlaceholderElement( html, tagName, replacement ) {
|
|
481
|
+
const start = html.indexOf( `<${ tagName }` );
|
|
482
|
+
if ( start === -1 ) {
|
|
483
|
+
return html;
|
|
484
|
+
}
|
|
485
|
+
const gt = html.indexOf( ">", start );
|
|
486
|
+
if ( gt === -1 ) {
|
|
487
|
+
return html;
|
|
488
|
+
}
|
|
489
|
+
// Tolerate whitespace(s) before '/>' and attributes on the tag:
|
|
490
|
+
let p = gt - 1;
|
|
491
|
+
while ( p > start && /\s/.test( html[ p ] ) ) p--;
|
|
492
|
+
const isSelfClosing = html[ p ] === "/";
|
|
493
|
+
let end;
|
|
494
|
+
let inner = "";
|
|
495
|
+
if ( isSelfClosing ) {
|
|
496
|
+
end = gt + 1;
|
|
497
|
+
} else {
|
|
498
|
+
const close = `</${ tagName }>`;
|
|
499
|
+
end = html.indexOf( close, gt + 1 );
|
|
500
|
+
if ( end === -1 ) {
|
|
501
|
+
return html;
|
|
502
|
+
}
|
|
503
|
+
inner = html.slice( gt + 1, end );
|
|
504
|
+
end += close.length;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Substitute the placeholder's attributes as `{ti-<name>}` tokens inside the replacement HTML:
|
|
508
|
+
const placeholderAttributes = this.#parsePlaceholderAttributes( html.slice( start, gt + 1 ) );
|
|
509
|
+
let processedReplacement = replacement;
|
|
510
|
+
Object.keys( placeholderAttributes ).forEach( ( name ) => {
|
|
511
|
+
const value = placeholderAttributes[ name ];
|
|
512
|
+
const token = `{ti-${ name }}`;
|
|
513
|
+
// Use split/join for a literal replaceAll without regex escaping concerns:
|
|
514
|
+
processedReplacement = processedReplacement.split( token ).join( value );
|
|
515
|
+
} );
|
|
516
|
+
|
|
517
|
+
let replacementWithInner = processedReplacement;
|
|
518
|
+
const slotMatch = processedReplacement.match( /<ti-slot\b[^>]*>[\s\S]*?<\/ti-slot>|<ti-slot\b[^>]*\/>/ );
|
|
519
|
+
if ( slotMatch ) {
|
|
520
|
+
// If a slot marker exists, the placeholder's inner content (when present) replaces it. When inner is
|
|
521
|
+
// empty, the slot's own default content (between <ti-slot> and </ti-slot>) is kept by unwrapping it:
|
|
522
|
+
if ( inner ) {
|
|
523
|
+
replacementWithInner = processedReplacement.replace( slotMatch[ 0 ], inner );
|
|
524
|
+
} else {
|
|
525
|
+
replacementWithInner = processedReplacement.replace( slotMatch[ 0 ], ( match ) => {
|
|
526
|
+
const defaultMatch = match.match( /<ti-slot\b[^>]*>([\s\S]*?)<\/ti-slot>/ );
|
|
527
|
+
return defaultMatch ? defaultMatch[ 1 ] : "";
|
|
528
|
+
} );
|
|
529
|
+
}
|
|
530
|
+
} else if ( inner ) {
|
|
531
|
+
const insertAt = processedReplacement.lastIndexOf( "</" );
|
|
532
|
+
if ( insertAt !== -1 ) {
|
|
533
|
+
replacementWithInner = processedReplacement.slice( 0, insertAt ) + inner + processedReplacement.slice( insertAt );
|
|
534
|
+
} else {
|
|
535
|
+
replacementWithInner = processedReplacement + inner;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return html.slice( 0, start ) + replacementWithInner + html.slice( end );
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Parses the attribute name/value pairs declared on a placeholder element's opening tag.
|
|
544
|
+
*
|
|
545
|
+
* @method
|
|
546
|
+
* @param {string} openingTag The full opening tag text, e.g. `<ti-foo-placeholder bar="baz">`.
|
|
547
|
+
* @returns {Object<string, string>}
|
|
548
|
+
* @private
|
|
549
|
+
*/
|
|
550
|
+
#parsePlaceholderAttributes( openingTag ) {
|
|
551
|
+
const attributes = {};
|
|
552
|
+
// Strip the element name and the surrounding angle brackets so only the attribute string remains:
|
|
553
|
+
const trimmed = openingTag.replace( /^<[^\s>/]+/, "" ).replace( /\/?>\s*$/, "" );
|
|
554
|
+
const regex = /([a-zA-Z_][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
|
|
555
|
+
let match;
|
|
556
|
+
while ( ( match = regex.exec( trimmed ) ) !== null ) {
|
|
557
|
+
attributes[ match[ 1 ] ] = match[ 2 ] ?? match[ 3 ] ?? match[ 4 ] ?? "";
|
|
558
|
+
}
|
|
559
|
+
return attributes;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
module.exports = TiWebAppManager;
|