aegisnode 0.0.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/LICENSE +21 -0
- package/README.md +2461 -0
- package/bin/aegisnode.js +9 -0
- package/package.json +56 -0
- package/scripts/smoke-test.js +1831 -0
- package/src/cli/commands/createapp.js +191 -0
- package/src/cli/commands/doctor.js +199 -0
- package/src/cli/commands/generate.js +266 -0
- package/src/cli/commands/runserver.js +17 -0
- package/src/cli/commands/startproject.js +72 -0
- package/src/cli/commands/updatedeps.js +355 -0
- package/src/cli/index.js +151 -0
- package/src/cli/utils/fs.js +53 -0
- package/src/cli/utils/project.js +67 -0
- package/src/cli/utils/scaffolds.js +596 -0
- package/src/index.js +20 -0
- package/src/runtime/auth.js +2291 -0
- package/src/runtime/cache.js +37 -0
- package/src/runtime/config.js +482 -0
- package/src/runtime/container.js +43 -0
- package/src/runtime/database.js +195 -0
- package/src/runtime/events.js +33 -0
- package/src/runtime/helpers.js +575 -0
- package/src/runtime/kernel.js +3713 -0
- package/src/runtime/loaders.js +46 -0
- package/src/runtime/logger.js +56 -0
- package/src/runtime/mail.js +225 -0
- package/src/runtime/upload.js +272 -0
- package/src/runtime/views/default-install.ejs +183 -0
- package/src/runtime/views/default-maintenance.ejs +148 -0
|
@@ -0,0 +1,3713 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import fsPromises from 'fs/promises';
|
|
3
|
+
import http from 'http';
|
|
4
|
+
import https from 'https';
|
|
5
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
9
|
+
import express from 'express';
|
|
10
|
+
import ejs from 'ejs';
|
|
11
|
+
import helmet from 'helmet';
|
|
12
|
+
import rateLimit from 'express-rate-limit';
|
|
13
|
+
import swaggerUi from 'swagger-ui-express';
|
|
14
|
+
import { Server as SocketIOServer } from 'socket.io';
|
|
15
|
+
import { createContainer } from './container.js';
|
|
16
|
+
import { createEventBus } from './events.js';
|
|
17
|
+
import { createLogger } from './logger.js';
|
|
18
|
+
import { createCache } from './cache.js';
|
|
19
|
+
import { deepMerge, loadProjectConfig, normalizeApps } from './config.js';
|
|
20
|
+
import { createAuthManager, normalizeAuthConfig } from './auth.js';
|
|
21
|
+
import { initializeDatabase, closeDatabase } from './database.js';
|
|
22
|
+
import { runLoaders } from './loaders.js';
|
|
23
|
+
import { createRuntimeHelpers } from './helpers.js';
|
|
24
|
+
import { createUploadManager, isMultipartRequestContentType, normalizeUploadsConfig } from './upload.js';
|
|
25
|
+
import { createMailManager, normalizeMailConfig } from './mail.js';
|
|
26
|
+
|
|
27
|
+
const ROUTE_DEFINITION = 'aegis:routes';
|
|
28
|
+
const PROJECT_ROUTE_DEFINITION = 'aegis:project-routes';
|
|
29
|
+
const DEFAULT_INSTALL_TEMPLATE_PATH = fileURLToPath(new URL('./views/default-install.ejs', import.meta.url));
|
|
30
|
+
const DEFAULT_MAINTENANCE_TEMPLATE_PATH = fileURLToPath(new URL('./views/default-maintenance.ejs', import.meta.url));
|
|
31
|
+
const RAW_HTML_SYMBOL = Symbol('aegis:raw-html');
|
|
32
|
+
const EMPTY_ROUTE_CONTEXT = Object.freeze({});
|
|
33
|
+
const REQUEST_I18N_CONTEXT = new AsyncLocalStorage();
|
|
34
|
+
|
|
35
|
+
function exists(filePath) {
|
|
36
|
+
return fs.existsSync(filePath);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isRouterInstance(value) {
|
|
40
|
+
return Boolean(value) && typeof value === 'function' && typeof value.use === 'function';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stripFileExtension(fileName) {
|
|
44
|
+
return fileName.replace(/\.[^.]+$/, '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeControllerName(fileName) {
|
|
48
|
+
return stripFileExtension(fileName).replace(/\.controller$/i, '').replace(/\.view$/i, '');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeServiceName(fileName) {
|
|
52
|
+
return stripFileExtension(fileName).replace(/\.service$/i, '');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeModelName(fileName) {
|
|
56
|
+
return stripFileExtension(fileName).replace(/\.model$/i, '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeValidatorName(fileName) {
|
|
60
|
+
return stripFileExtension(fileName).replace(/\.validator$/i, '');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isPlainObject(value) {
|
|
64
|
+
return Boolean(value) && Object.prototype.toString.call(value) === '[object Object]';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeArchitectureConfig(rawArchitecture) {
|
|
68
|
+
const architecture = isPlainObject(rawArchitecture) ? rawArchitecture : {};
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
strictLayers: architecture.strictLayers === true,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeMountPrefix(value) {
|
|
76
|
+
if (!value || value === '/') {
|
|
77
|
+
return '/';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return `/${String(value).trim().replace(/^\/+/, '').replace(/\/+$/, '')}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeRoutePrefix(value, fallback) {
|
|
84
|
+
const normalized = normalizeMountPrefix(value || fallback);
|
|
85
|
+
return normalized === '/' ? fallback : normalized;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeApiConfig(rawApi, apps = []) {
|
|
89
|
+
const api = isPlainObject(rawApi) ? rawApi : {};
|
|
90
|
+
const configuredApps = Array.isArray(api.apps)
|
|
91
|
+
? api.apps
|
|
92
|
+
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
|
|
93
|
+
.map((entry) => entry.trim())
|
|
94
|
+
: [];
|
|
95
|
+
|
|
96
|
+
const appMounts = new Map(
|
|
97
|
+
(Array.isArray(apps) ? apps : [])
|
|
98
|
+
.filter((entry) => entry && typeof entry === 'object' && typeof entry.name === 'string')
|
|
99
|
+
.map((entry) => [entry.name, normalizeMountPrefix(entry.mount || `/${entry.name}`)]),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const mounts = [];
|
|
103
|
+
for (const appName of configuredApps) {
|
|
104
|
+
const mount = normalizeMountPrefix(appMounts.get(appName) || `/${appName}`);
|
|
105
|
+
if (!mounts.includes(mount)) {
|
|
106
|
+
mounts.push(mount);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
apps: configuredApps,
|
|
112
|
+
mounts,
|
|
113
|
+
disableCsrf: api.disableCsrf !== false,
|
|
114
|
+
requireJsonForUnsafeMethods: api.requireJsonForUnsafeMethods !== false,
|
|
115
|
+
noStoreHeaders: api.noStoreHeaders !== false,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeSwaggerConfig(rawSwagger) {
|
|
120
|
+
const swagger = isPlainObject(rawSwagger) ? rawSwagger : {};
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
enabled: swagger.enabled === true,
|
|
124
|
+
docsPath: normalizeRoutePrefix(swagger.docsPath, '/docs'),
|
|
125
|
+
jsonPath: normalizeRoutePrefix(swagger.jsonPath, '/openapi.json'),
|
|
126
|
+
document: isPlainObject(swagger.document) ? swagger.document : null,
|
|
127
|
+
documentPath: typeof swagger.documentPath === 'string' && swagger.documentPath.trim().length > 0
|
|
128
|
+
? swagger.documentPath.trim()
|
|
129
|
+
: 'openapi.json',
|
|
130
|
+
explorer: swagger.explorer !== false,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeHttpsPathValue(value) {
|
|
135
|
+
if (typeof value !== 'string') {
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return value.trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeHttpsPathList(value) {
|
|
143
|
+
if (Array.isArray(value)) {
|
|
144
|
+
return value
|
|
145
|
+
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
|
|
146
|
+
.map((entry) => entry.trim());
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const normalized = normalizeHttpsPathValue(value);
|
|
150
|
+
return normalized ? [normalized] : [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizeHttpsAssetValue(value) {
|
|
154
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function normalizeHttpsAssetList(value) {
|
|
166
|
+
if (Array.isArray(value)) {
|
|
167
|
+
return value
|
|
168
|
+
.map((entry) => normalizeHttpsAssetValue(entry))
|
|
169
|
+
.filter((entry) => entry !== null);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const normalized = normalizeHttpsAssetValue(value);
|
|
173
|
+
return normalized ? [normalized] : [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizeHttpsConfig(rawHttps) {
|
|
177
|
+
const httpsConfig = rawHttps === true
|
|
178
|
+
? { enabled: true }
|
|
179
|
+
: (isPlainObject(rawHttps) ? rawHttps : {});
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
enabled: httpsConfig.enabled === true,
|
|
183
|
+
key: normalizeHttpsAssetValue(httpsConfig.key),
|
|
184
|
+
cert: normalizeHttpsAssetValue(httpsConfig.cert),
|
|
185
|
+
ca: normalizeHttpsAssetList(httpsConfig.ca),
|
|
186
|
+
pfx: normalizeHttpsAssetValue(httpsConfig.pfx),
|
|
187
|
+
keyPath: normalizeHttpsPathValue(httpsConfig.keyPath),
|
|
188
|
+
certPath: normalizeHttpsPathValue(httpsConfig.certPath),
|
|
189
|
+
caPath: normalizeHttpsPathList(httpsConfig.caPath),
|
|
190
|
+
pfxPath: normalizeHttpsPathValue(httpsConfig.pfxPath),
|
|
191
|
+
passphrase: typeof httpsConfig.passphrase === 'string' ? httpsConfig.passphrase : '',
|
|
192
|
+
options: isPlainObject(httpsConfig.options) ? { ...httpsConfig.options } : {},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isClassConstructor(value) {
|
|
197
|
+
if (typeof value !== 'function') {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const source = Function.prototype.toString.call(value);
|
|
202
|
+
return source.startsWith('class ');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function markRawHtml(value) {
|
|
206
|
+
return { [RAW_HTML_SYMBOL]: String(value) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isRawHtml(value) {
|
|
210
|
+
return Boolean(value) && typeof value === 'object' && RAW_HTML_SYMBOL in value;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function escapeHtmlValue(value) {
|
|
214
|
+
if (isRawHtml(value)) {
|
|
215
|
+
return value[RAW_HTML_SYMBOL];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return ejs.escapeXML(value);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function normalizeTemplateName(templateName, label = 'template') {
|
|
222
|
+
if (typeof templateName !== 'string' || templateName.trim().length === 0) {
|
|
223
|
+
throw new Error(`${label} name must be a non-empty string.`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const normalized = templateName
|
|
227
|
+
.trim()
|
|
228
|
+
.replace(/\\/g, '/')
|
|
229
|
+
.replace(/^\/+/, '')
|
|
230
|
+
.replace(/\/+/g, '/')
|
|
231
|
+
.replace(/\.ejs$/i, '');
|
|
232
|
+
|
|
233
|
+
if (!normalized || normalized === '.' || normalized === '..') {
|
|
234
|
+
throw new Error(`Invalid ${label} name "${templateName}"`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const segments = normalized.split('/');
|
|
238
|
+
if (segments.some((segment) => segment.length === 0 || segment === '..')) {
|
|
239
|
+
throw new Error(`Invalid ${label} name "${templateName}"`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return normalized;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function normalizeTemplatesConfig(rawTemplates, rootDir) {
|
|
246
|
+
if (rawTemplates === false) {
|
|
247
|
+
return {
|
|
248
|
+
enabled: false,
|
|
249
|
+
engine: 'ejs',
|
|
250
|
+
dir: 'templates',
|
|
251
|
+
root: path.join(rootDir, 'templates'),
|
|
252
|
+
base: null,
|
|
253
|
+
appBases: {},
|
|
254
|
+
locals: {},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const source = rawTemplates && typeof rawTemplates === 'object' ? rawTemplates : {};
|
|
259
|
+
const enabled = source.enabled !== false;
|
|
260
|
+
const engine = String(source.engine || 'ejs').toLowerCase();
|
|
261
|
+
|
|
262
|
+
if (engine !== 'ejs') {
|
|
263
|
+
throw new Error(`Unsupported template engine "${engine}". Only "ejs" is supported.`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const dir = typeof source.dir === 'string' && source.dir.trim().length > 0
|
|
267
|
+
? source.dir.trim()
|
|
268
|
+
: 'templates';
|
|
269
|
+
const root = path.isAbsolute(dir) ? dir : path.join(rootDir, dir);
|
|
270
|
+
const base = source.base === false || source.base === null
|
|
271
|
+
? null
|
|
272
|
+
: (typeof source.base === 'string' && source.base.trim().length > 0
|
|
273
|
+
? normalizeTemplateName(source.base, 'base template')
|
|
274
|
+
: 'base');
|
|
275
|
+
const appBasesSource = isPlainObject(source.appBases) ? source.appBases : {};
|
|
276
|
+
const appBases = {};
|
|
277
|
+
for (const [appName, layoutName] of Object.entries(appBasesSource)) {
|
|
278
|
+
if (typeof appName !== 'string' || appName.trim().length === 0) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const normalizedAppName = appName.trim();
|
|
283
|
+
if (layoutName === false || layoutName === null) {
|
|
284
|
+
appBases[normalizedAppName] = null;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (typeof layoutName === 'string' && layoutName.trim().length > 0) {
|
|
289
|
+
appBases[normalizedAppName] = normalizeTemplateName(layoutName, 'app base template for ' + normalizedAppName);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const locals = typeof source.locals === 'function'
|
|
294
|
+
? source.locals
|
|
295
|
+
: (isPlainObject(source.locals) ? source.locals : {});
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
enabled,
|
|
299
|
+
engine,
|
|
300
|
+
dir,
|
|
301
|
+
root,
|
|
302
|
+
base,
|
|
303
|
+
appBases,
|
|
304
|
+
locals,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function normalizeMaintenanceConfig(rawMaintenance, appName = 'AegisNode') {
|
|
309
|
+
if (rawMaintenance === false || rawMaintenance === null || rawMaintenance === undefined) {
|
|
310
|
+
return {
|
|
311
|
+
enabled: false,
|
|
312
|
+
statusCode: 503,
|
|
313
|
+
route: null,
|
|
314
|
+
html: '',
|
|
315
|
+
excludePaths: [],
|
|
316
|
+
retryAfter: null,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const source = typeof rawMaintenance === 'string'
|
|
321
|
+
? { enabled: true, html: rawMaintenance }
|
|
322
|
+
: (rawMaintenance === true
|
|
323
|
+
? { enabled: true }
|
|
324
|
+
: (isPlainObject(rawMaintenance) ? rawMaintenance : {}));
|
|
325
|
+
|
|
326
|
+
const rawHtml = typeof source.html === 'string' ? source.html : '';
|
|
327
|
+
const statusCodeNumber = Number(source.statusCode);
|
|
328
|
+
const route = typeof source.route === 'string' && source.route.trim().length > 0
|
|
329
|
+
? normalizeMountPrefix(source.route.trim())
|
|
330
|
+
: null;
|
|
331
|
+
const excludePaths = Array.isArray(source.excludePaths)
|
|
332
|
+
? [...new Set(
|
|
333
|
+
source.excludePaths
|
|
334
|
+
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
|
|
335
|
+
.map((entry) => normalizeMountPrefix(entry.trim())),
|
|
336
|
+
)]
|
|
337
|
+
: [];
|
|
338
|
+
const retryAfter = typeof source.retryAfter === 'string' && source.retryAfter.trim().length > 0
|
|
339
|
+
? source.retryAfter.trim()
|
|
340
|
+
: (Number.isFinite(Number(source.retryAfter)) && Number(source.retryAfter) >= 0
|
|
341
|
+
? String(Math.floor(Number(source.retryAfter)))
|
|
342
|
+
: null);
|
|
343
|
+
const enabled = rawMaintenance === true
|
|
344
|
+
|| typeof rawMaintenance === 'string'
|
|
345
|
+
|| source.enabled === true
|
|
346
|
+
|| (rawHtml.trim().length > 0 && source.enabled !== false);
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
enabled,
|
|
350
|
+
statusCode: Number.isInteger(statusCodeNumber) && statusCodeNumber >= 400 && statusCodeNumber <= 599
|
|
351
|
+
? statusCodeNumber
|
|
352
|
+
: 503,
|
|
353
|
+
route,
|
|
354
|
+
html: rawHtml.trim(),
|
|
355
|
+
excludePaths,
|
|
356
|
+
retryAfter,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function resolveTemplateFilePath(templatesRoot, templateName) {
|
|
361
|
+
const normalizedName = normalizeTemplateName(templateName);
|
|
362
|
+
const target = path.resolve(templatesRoot, `${normalizedName}.ejs`);
|
|
363
|
+
const relative = path.relative(templatesRoot, target);
|
|
364
|
+
|
|
365
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
366
|
+
throw new Error(`Invalid template path "${templateName}"`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return target;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function renderTemplateFile({ templatesRoot, templateName, locals }) {
|
|
373
|
+
const templateFile = resolveTemplateFilePath(templatesRoot, templateName);
|
|
374
|
+
return ejs.renderFile(templateFile, locals, {
|
|
375
|
+
async: true,
|
|
376
|
+
escape: escapeHtmlValue,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function normalizeSecurityHeadersConfig(rawSecurity) {
|
|
381
|
+
const security = isPlainObject(rawSecurity) ? rawSecurity : {};
|
|
382
|
+
const headers = isPlainObject(security.headers) ? security.headers : {};
|
|
383
|
+
const csp = isPlainObject(headers.csp) ? headers.csp : {};
|
|
384
|
+
|
|
385
|
+
const directives = isPlainObject(csp.directives) ? csp.directives : {};
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
enabled: headers.enabled !== false,
|
|
389
|
+
csp: {
|
|
390
|
+
enabled: csp.enabled !== false,
|
|
391
|
+
reportOnly: csp.reportOnly === true,
|
|
392
|
+
directives,
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function buildCspDirectives(userDirectives) {
|
|
398
|
+
const defaults = {
|
|
399
|
+
defaultSrc: ["'self'"],
|
|
400
|
+
baseUri: ["'self'"],
|
|
401
|
+
frameAncestors: ["'none'"],
|
|
402
|
+
objectSrc: ["'none'"],
|
|
403
|
+
scriptSrc: ["'self'"],
|
|
404
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
405
|
+
imgSrc: ["'self'", 'data:', 'https:'],
|
|
406
|
+
fontSrc: ["'self'", 'data:', 'https:'],
|
|
407
|
+
connectSrc: ["'self'", 'ws:', 'wss:'],
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const merged = { ...defaults };
|
|
411
|
+
|
|
412
|
+
for (const [key, value] of Object.entries(userDirectives || {})) {
|
|
413
|
+
if (value === false || value === null) {
|
|
414
|
+
delete merged[key];
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (Array.isArray(value)) {
|
|
419
|
+
merged[key] = value.map((item) => String(item));
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (typeof value === 'string') {
|
|
424
|
+
merged[key] = [value];
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return merged;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function normalizeSameSite(value) {
|
|
432
|
+
if (value === false) {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const normalized = String(value || 'lax').toLowerCase();
|
|
437
|
+
if (normalized === 'strict') {
|
|
438
|
+
return 'strict';
|
|
439
|
+
}
|
|
440
|
+
if (normalized === 'none') {
|
|
441
|
+
return 'none';
|
|
442
|
+
}
|
|
443
|
+
return 'lax';
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function normalizeCsrfConfig(rawSecurity) {
|
|
447
|
+
const security = isPlainObject(rawSecurity) ? rawSecurity : {};
|
|
448
|
+
const csrf = isPlainObject(security.csrf) ? security.csrf : {};
|
|
449
|
+
const secureRaw = csrf.secure;
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
enabled: csrf.enabled !== false,
|
|
453
|
+
rejectForms: csrf.rejectForms !== false,
|
|
454
|
+
rejectUnsafeMethods: csrf.rejectUnsafeMethods !== false,
|
|
455
|
+
cookieName: typeof csrf.cookieName === 'string' && csrf.cookieName.trim().length > 0
|
|
456
|
+
? csrf.cookieName.trim()
|
|
457
|
+
: '_aegis_csrf',
|
|
458
|
+
fieldName: typeof csrf.fieldName === 'string' && csrf.fieldName.trim().length > 0
|
|
459
|
+
? csrf.fieldName.trim()
|
|
460
|
+
: '_csrf',
|
|
461
|
+
headerName: typeof csrf.headerName === 'string' && csrf.headerName.trim().length > 0
|
|
462
|
+
? csrf.headerName.trim().toLowerCase()
|
|
463
|
+
: 'x-csrf-token',
|
|
464
|
+
requireSignedCookie: csrf.requireSignedCookie !== false,
|
|
465
|
+
sameSite: normalizeSameSite(csrf.sameSite),
|
|
466
|
+
secure: secureRaw === true || secureRaw === false ? secureRaw : 'auto',
|
|
467
|
+
httpOnly: csrf.httpOnly !== false,
|
|
468
|
+
path: typeof csrf.path === 'string' && csrf.path.trim().length > 0 ? csrf.path : '/',
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function resolveAppSecret(rawSecurity) {
|
|
473
|
+
const security = isPlainObject(rawSecurity) ? rawSecurity : {};
|
|
474
|
+
if (typeof security.appSecret !== 'string') {
|
|
475
|
+
return '';
|
|
476
|
+
}
|
|
477
|
+
const secret = security.appSecret.trim();
|
|
478
|
+
return secret.length >= 16 ? secret : '';
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function generateAppSecret() {
|
|
482
|
+
return crypto.randomBytes(32).toString('hex');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function ensureAppSecret(config, rootDir, logger) {
|
|
486
|
+
if (!isPlainObject(config.security)) {
|
|
487
|
+
config.security = {};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const configuredSecret = resolveAppSecret(config.security);
|
|
491
|
+
if (configuredSecret) {
|
|
492
|
+
config.security.appSecret = configuredSecret;
|
|
493
|
+
return configuredSecret;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const secretDirectory = path.join(rootDir || process.cwd(), '.aegis');
|
|
497
|
+
const secretFile = path.join(secretDirectory, 'app-secret');
|
|
498
|
+
|
|
499
|
+
if (exists(secretFile)) {
|
|
500
|
+
try {
|
|
501
|
+
const persistedSecret = fs.readFileSync(secretFile, 'utf8').trim();
|
|
502
|
+
if (persistedSecret.length >= 16) {
|
|
503
|
+
config.security.appSecret = persistedSecret;
|
|
504
|
+
logger.warn(
|
|
505
|
+
'security.appSecret is missing; using persisted fallback secret from %s. Prefer APP_SECRET or settings.security.appSecret.',
|
|
506
|
+
secretFile,
|
|
507
|
+
);
|
|
508
|
+
return persistedSecret;
|
|
509
|
+
}
|
|
510
|
+
} catch (error) {
|
|
511
|
+
logger.warn(
|
|
512
|
+
'security.appSecret fallback file could not be read at %s: %s',
|
|
513
|
+
secretFile,
|
|
514
|
+
error?.message || String(error),
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const generatedSecret = generateAppSecret();
|
|
520
|
+
config.security.appSecret = generatedSecret;
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
fs.mkdirSync(secretDirectory, { recursive: true });
|
|
524
|
+
fs.writeFileSync(secretFile, `${generatedSecret}\n`, {
|
|
525
|
+
encoding: 'utf8',
|
|
526
|
+
mode: 0o600,
|
|
527
|
+
});
|
|
528
|
+
logger.warn(
|
|
529
|
+
'security.appSecret is missing; generated and persisted a fallback secret at %s. Prefer APP_SECRET or settings.security.appSecret.',
|
|
530
|
+
secretFile,
|
|
531
|
+
);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
logger.warn(
|
|
534
|
+
'security.appSecret is missing; generated an in-memory fallback secret for this boot because persistence failed: %s',
|
|
535
|
+
error?.message || String(error),
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return generatedSecret;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function signToken(token, secret) {
|
|
543
|
+
return crypto.createHmac('sha256', secret).update(token).digest('hex');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function encodeCsrfCookieValue(token, secret) {
|
|
547
|
+
if (!secret) {
|
|
548
|
+
return token;
|
|
549
|
+
}
|
|
550
|
+
return `${token}.${signToken(token, secret)}`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function decodeCsrfCookieValue(cookieValue, secret) {
|
|
554
|
+
if (typeof cookieValue !== 'string' || cookieValue.length === 0) {
|
|
555
|
+
return { valid: false, token: '' };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (!secret) {
|
|
559
|
+
return { valid: true, token: cookieValue };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const dotIndex = cookieValue.lastIndexOf('.');
|
|
563
|
+
if (dotIndex <= 0) {
|
|
564
|
+
return { valid: false, token: '' };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const token = cookieValue.slice(0, dotIndex);
|
|
568
|
+
const signature = cookieValue.slice(dotIndex + 1);
|
|
569
|
+
const expected = signToken(token, secret);
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
valid: constantTimeEqual(signature, expected),
|
|
573
|
+
token,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function normalizeTrustProxySetting(value) {
|
|
578
|
+
if (value === true || value === false) {
|
|
579
|
+
return value;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
|
|
583
|
+
return value;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
587
|
+
return value.trim();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function resolveTrustProxySetting(config) {
|
|
594
|
+
const security = isPlainObject(config?.security) ? config.security : {};
|
|
595
|
+
const ddos = isPlainObject(security.ddos) ? security.ddos : {};
|
|
596
|
+
const topLevel = normalizeTrustProxySetting(config?.trustProxy);
|
|
597
|
+
if (topLevel !== false) {
|
|
598
|
+
return topLevel;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const legacy = normalizeTrustProxySetting(ddos.trustProxy);
|
|
602
|
+
return legacy !== false ? legacy : false;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function applyTrustProxySetting(expressApp, config, logger) {
|
|
606
|
+
const trustProxy = resolveTrustProxySetting(config);
|
|
607
|
+
config.trustProxy = trustProxy;
|
|
608
|
+
expressApp.set('trust proxy', trustProxy);
|
|
609
|
+
|
|
610
|
+
if (trustProxy !== false) {
|
|
611
|
+
logger.debug('Express trust proxy enabled: %o', trustProxy);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function normalizeDdosConfig(rawSecurity) {
|
|
616
|
+
const security = isPlainObject(rawSecurity) ? rawSecurity : {};
|
|
617
|
+
const ddos = isPlainObject(security.ddos) ? security.ddos : {};
|
|
618
|
+
const windowMsNumber = Number(ddos.windowMs);
|
|
619
|
+
const maxRequestsNumber = Number(ddos.maxRequests);
|
|
620
|
+
const statusCodeNumber = Number(ddos.statusCode);
|
|
621
|
+
|
|
622
|
+
const skipPaths = Array.isArray(ddos.skipPaths)
|
|
623
|
+
? ddos.skipPaths
|
|
624
|
+
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
|
|
625
|
+
.map((entry) => {
|
|
626
|
+
const trimmed = entry.trim();
|
|
627
|
+
if (trimmed === '/') {
|
|
628
|
+
return '/';
|
|
629
|
+
}
|
|
630
|
+
return `/${trimmed.replace(/^\/+/, '').replace(/\/+$/, '')}`;
|
|
631
|
+
})
|
|
632
|
+
: ['/health'];
|
|
633
|
+
|
|
634
|
+
return {
|
|
635
|
+
enabled: ddos.enabled !== false,
|
|
636
|
+
windowMs: Number.isFinite(windowMsNumber) && windowMsNumber > 0 ? Math.floor(windowMsNumber) : 60_000,
|
|
637
|
+
maxRequests: Number.isFinite(maxRequestsNumber) && maxRequestsNumber > 0 ? Math.floor(maxRequestsNumber) : 120,
|
|
638
|
+
message: typeof ddos.message === 'string' && ddos.message.trim().length > 0
|
|
639
|
+
? ddos.message.trim()
|
|
640
|
+
: 'Too many requests, please try again later.',
|
|
641
|
+
statusCode: Number.isFinite(statusCodeNumber) && statusCodeNumber >= 400 ? Math.floor(statusCodeNumber) : 429,
|
|
642
|
+
standardHeaders: ddos.standardHeaders !== false,
|
|
643
|
+
legacyHeaders: ddos.legacyHeaders === true,
|
|
644
|
+
skipSuccessfulRequests: ddos.skipSuccessfulRequests === true,
|
|
645
|
+
skipFailedRequests: ddos.skipFailedRequests === true,
|
|
646
|
+
trustProxy: normalizeTrustProxySetting(ddos.trustProxy),
|
|
647
|
+
store: ddos.store && typeof ddos.store === 'object' ? ddos.store : null,
|
|
648
|
+
skipPaths,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const DB_LIBRARY_PATTERN = /\b(querymesh|mongoose|pg|postgres|postgresql|mysql|mysql2|mssql|sequelize|mongodb|knex|@prisma\/client)\b/i;
|
|
653
|
+
const IMPORT_FROM_PATTERN = /import\s+[\s\S]*?\sfrom\s+['"]([^'"]+)['"]/g;
|
|
654
|
+
const IMPORT_SIDE_EFFECT_PATTERN = /import\s+['"]([^'"]+)['"]/g;
|
|
655
|
+
const REQUIRE_PATTERN = /require\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
656
|
+
const MODEL_IMPORT_PATH_PATTERN = /(?:^|\/)(models(?:\.js)?|[^/]+\.model(?:\.js)?)$/i;
|
|
657
|
+
|
|
658
|
+
function extractImportSpecifiers(source) {
|
|
659
|
+
const imports = [];
|
|
660
|
+
const patterns = [IMPORT_FROM_PATTERN, IMPORT_SIDE_EFFECT_PATTERN, REQUIRE_PATTERN];
|
|
661
|
+
|
|
662
|
+
for (const pattern of patterns) {
|
|
663
|
+
pattern.lastIndex = 0;
|
|
664
|
+
let match = pattern.exec(source);
|
|
665
|
+
while (match) {
|
|
666
|
+
imports.push(match[1]);
|
|
667
|
+
match = pattern.exec(source);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return imports;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function routeFileHasModelAccess(content) {
|
|
675
|
+
if (/['"]model:[^'"]+['"]/i.test(content)) {
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const imports = extractImportSpecifiers(content);
|
|
680
|
+
return imports.some((specifier) => MODEL_IMPORT_PATH_PATTERN.test(specifier));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function fileImportsDatabaseLibrary(content) {
|
|
684
|
+
const imports = extractImportSpecifiers(content);
|
|
685
|
+
return imports.some((specifier) => DB_LIBRARY_PATTERN.test(specifier));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function serviceFileHasDatabaseAccess(content) {
|
|
689
|
+
return /\b(dbClient|database)\b/.test(content);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function collectStrictLayerFiles(appRoot) {
|
|
693
|
+
const routeFiles = [];
|
|
694
|
+
const serviceFiles = [];
|
|
695
|
+
|
|
696
|
+
const routesFile = path.join(appRoot, 'routes.js');
|
|
697
|
+
if (exists(routesFile)) {
|
|
698
|
+
routeFiles.push(routesFile);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const routesDir = path.join(appRoot, 'routes');
|
|
702
|
+
for (const fileName of await loadDirectoryFiles(routesDir)) {
|
|
703
|
+
if (!fileName.endsWith('.js')) {
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
routeFiles.push(path.join(routesDir, fileName));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const servicesFile = path.join(appRoot, 'services.js');
|
|
710
|
+
if (exists(servicesFile)) {
|
|
711
|
+
serviceFiles.push(servicesFile);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
for (const fileName of await loadDirectoryFiles(appRoot)) {
|
|
715
|
+
if (!fileName.endsWith('.service.js')) {
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
serviceFiles.push(path.join(appRoot, fileName));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const servicesDir = path.join(appRoot, 'services');
|
|
722
|
+
for (const fileName of await loadDirectoryFiles(servicesDir)) {
|
|
723
|
+
if (!fileName.endsWith('.js')) {
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
serviceFiles.push(path.join(servicesDir, fileName));
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return { routeFiles, serviceFiles };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function enforceStrictLayersForFile(filePath, checks) {
|
|
733
|
+
const source = await fsPromises.readFile(filePath, 'utf8');
|
|
734
|
+
|
|
735
|
+
for (const check of checks) {
|
|
736
|
+
if (check.when(source)) {
|
|
737
|
+
throw new Error(`[strictLayers] ${check.message} (${filePath})`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function enforceStrictLayerArchitecture({ appName, appRoot }) {
|
|
743
|
+
const { routeFiles, serviceFiles } = await collectStrictLayerFiles(appRoot);
|
|
744
|
+
|
|
745
|
+
const routeChecks = [
|
|
746
|
+
{
|
|
747
|
+
when: (source) => routeFileHasModelAccess(source),
|
|
748
|
+
message: `Routes in app "${appName}" must call services only (model imports/tokens are not allowed)`,
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
when: (source) => fileImportsDatabaseLibrary(source),
|
|
752
|
+
message: `Routes in app "${appName}" must not import database libraries`,
|
|
753
|
+
},
|
|
754
|
+
];
|
|
755
|
+
|
|
756
|
+
const serviceChecks = [
|
|
757
|
+
{
|
|
758
|
+
when: (source) => fileImportsDatabaseLibrary(source),
|
|
759
|
+
message: `Services in app "${appName}" must not import database libraries directly`,
|
|
760
|
+
},
|
|
761
|
+
{
|
|
762
|
+
when: (source) => serviceFileHasDatabaseAccess(source),
|
|
763
|
+
message: `Services in app "${appName}" must access data through models (dbClient/database usage is not allowed)`,
|
|
764
|
+
},
|
|
765
|
+
];
|
|
766
|
+
|
|
767
|
+
for (const filePath of routeFiles) {
|
|
768
|
+
await enforceStrictLayersForFile(filePath, routeChecks);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
for (const filePath of serviceFiles) {
|
|
772
|
+
await enforceStrictLayersForFile(filePath, serviceChecks);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async function enforceStrictProjectRoutes(projectRoutesFile) {
|
|
777
|
+
if (!projectRoutesFile || !exists(projectRoutesFile)) {
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
await enforceStrictLayersForFile(projectRoutesFile, [
|
|
782
|
+
{
|
|
783
|
+
when: (source) => routeFileHasModelAccess(source),
|
|
784
|
+
message: 'Project routes must call services only (model imports/tokens are not allowed)',
|
|
785
|
+
},
|
|
786
|
+
{
|
|
787
|
+
when: (source) => fileImportsDatabaseLibrary(source),
|
|
788
|
+
message: 'Project routes must not import database libraries',
|
|
789
|
+
},
|
|
790
|
+
]);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function parseLayerIdentifier(identifier, defaultAppName, layerName) {
|
|
794
|
+
const normalized = typeof identifier === 'string' ? identifier.trim() : '';
|
|
795
|
+
if (!normalized) {
|
|
796
|
+
throw new Error(`Invalid ${layerName} identifier. Provide "<name>" or "<app>.<name>".`);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const parts = normalized.split('.').filter(Boolean);
|
|
800
|
+
if (parts.length === 1) {
|
|
801
|
+
if (!defaultAppName) {
|
|
802
|
+
throw new Error(`Ambiguous ${layerName} identifier "${identifier}". Use "<app>.<name>".`);
|
|
803
|
+
}
|
|
804
|
+
return {
|
|
805
|
+
appName: defaultAppName,
|
|
806
|
+
name: parts[0],
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (parts.length === 2) {
|
|
811
|
+
return {
|
|
812
|
+
appName: parts[0],
|
|
813
|
+
name: parts[1],
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
throw new Error(`Invalid ${layerName} identifier "${identifier}". Use "<name>" or "<app>.<name>".`);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function instantiateLayerEntry(entry, dependencies) {
|
|
821
|
+
if (isClassConstructor(entry)) {
|
|
822
|
+
return new entry(dependencies);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return entry;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function createLayerAccessors({ container, context }) {
|
|
829
|
+
const modelInstanceCache = new Map();
|
|
830
|
+
const validatorInstanceCache = new Map();
|
|
831
|
+
const serviceInstanceCache = new Map();
|
|
832
|
+
|
|
833
|
+
function createModelAccessor(defaultAppName = null) {
|
|
834
|
+
return {
|
|
835
|
+
get(identifier) {
|
|
836
|
+
const { appName, name } = parseLayerIdentifier(identifier, defaultAppName, 'model');
|
|
837
|
+
const scopedToken = `model:${appName}.${name}`;
|
|
838
|
+
const fallbackToken = `model:${appName}`;
|
|
839
|
+
const token = container.has(scopedToken) ? scopedToken : fallbackToken;
|
|
840
|
+
|
|
841
|
+
if (!container.has(token)) {
|
|
842
|
+
throw new Error(`Model not found for token ${scopedToken}`);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (modelInstanceCache.has(token)) {
|
|
846
|
+
return modelInstanceCache.get(token);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const model = instantiateLayerEntry(container.get(token), {
|
|
850
|
+
appName,
|
|
851
|
+
config: context.config,
|
|
852
|
+
env: context.env,
|
|
853
|
+
i18n: context.i18n,
|
|
854
|
+
mail: context.mail,
|
|
855
|
+
logger: context.logger,
|
|
856
|
+
events: context.events,
|
|
857
|
+
cache: context.cache,
|
|
858
|
+
io: context.io,
|
|
859
|
+
helpers: context.helpers,
|
|
860
|
+
jlive: context.jlive,
|
|
861
|
+
dbClient: context.dbClient,
|
|
862
|
+
database: context.database,
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
modelInstanceCache.set(token, model);
|
|
866
|
+
return model;
|
|
867
|
+
},
|
|
868
|
+
has(identifier) {
|
|
869
|
+
const { appName, name } = parseLayerIdentifier(identifier, defaultAppName, 'model');
|
|
870
|
+
return container.has(`model:${appName}.${name}`) || container.has(`model:${appName}`);
|
|
871
|
+
},
|
|
872
|
+
forApp(appName) {
|
|
873
|
+
return createModelAccessor(appName);
|
|
874
|
+
},
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function createServiceAccessor(defaultAppName = null) {
|
|
879
|
+
return {
|
|
880
|
+
get(identifier) {
|
|
881
|
+
const { appName, name } = parseLayerIdentifier(identifier, defaultAppName, 'service');
|
|
882
|
+
const scopedToken = `service:${appName}.${name}`;
|
|
883
|
+
const fallbackToken = `service:${appName}`;
|
|
884
|
+
const token = container.has(scopedToken) ? scopedToken : fallbackToken;
|
|
885
|
+
|
|
886
|
+
if (!container.has(token)) {
|
|
887
|
+
throw new Error(`Service not found for token ${scopedToken}`);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (serviceInstanceCache.has(token)) {
|
|
891
|
+
return serviceInstanceCache.get(token);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const services = createServiceAccessor(appName);
|
|
895
|
+
const models = createModelAccessor(appName);
|
|
896
|
+
const validators = createValidatorAccessor(appName);
|
|
897
|
+
const service = instantiateLayerEntry(container.get(token), {
|
|
898
|
+
appName,
|
|
899
|
+
config: context.config,
|
|
900
|
+
env: context.env,
|
|
901
|
+
i18n: context.i18n,
|
|
902
|
+
mail: context.mail,
|
|
903
|
+
logger: context.logger,
|
|
904
|
+
events: context.events,
|
|
905
|
+
cache: context.cache,
|
|
906
|
+
io: context.io,
|
|
907
|
+
auth: context.auth,
|
|
908
|
+
helpers: context.helpers,
|
|
909
|
+
jlive: context.jlive,
|
|
910
|
+
models,
|
|
911
|
+
validators,
|
|
912
|
+
services,
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
serviceInstanceCache.set(token, service);
|
|
916
|
+
return service;
|
|
917
|
+
},
|
|
918
|
+
has(identifier) {
|
|
919
|
+
const { appName, name } = parseLayerIdentifier(identifier, defaultAppName, 'service');
|
|
920
|
+
return container.has(`service:${appName}.${name}`) || container.has(`service:${appName}`);
|
|
921
|
+
},
|
|
922
|
+
forApp(appName) {
|
|
923
|
+
return createServiceAccessor(appName);
|
|
924
|
+
},
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function createValidatorAccessor(defaultAppName = null) {
|
|
929
|
+
return {
|
|
930
|
+
get(identifier) {
|
|
931
|
+
const { appName, name } = parseLayerIdentifier(identifier, defaultAppName, 'validator');
|
|
932
|
+
const scopedToken = `validator:${appName}.${name}`;
|
|
933
|
+
const fallbackToken = `validator:${appName}`;
|
|
934
|
+
const token = container.has(scopedToken) ? scopedToken : fallbackToken;
|
|
935
|
+
|
|
936
|
+
if (!container.has(token)) {
|
|
937
|
+
throw new Error(`Validator not found for token ${scopedToken}`);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (validatorInstanceCache.has(token)) {
|
|
941
|
+
return validatorInstanceCache.get(token);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const validator = instantiateLayerEntry(container.get(token), {
|
|
945
|
+
appName,
|
|
946
|
+
config: context.config,
|
|
947
|
+
env: context.env,
|
|
948
|
+
i18n: context.i18n,
|
|
949
|
+
mail: context.mail,
|
|
950
|
+
logger: context.logger,
|
|
951
|
+
events: context.events,
|
|
952
|
+
cache: context.cache,
|
|
953
|
+
io: context.io,
|
|
954
|
+
auth: context.auth,
|
|
955
|
+
helpers: context.helpers,
|
|
956
|
+
jlive: context.jlive,
|
|
957
|
+
dbClient: context.dbClient,
|
|
958
|
+
database: context.database,
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
validatorInstanceCache.set(token, validator);
|
|
962
|
+
return validator;
|
|
963
|
+
},
|
|
964
|
+
has(identifier) {
|
|
965
|
+
const { appName, name } = parseLayerIdentifier(identifier, defaultAppName, 'validator');
|
|
966
|
+
return container.has(`validator:${appName}.${name}`) || container.has(`validator:${appName}`);
|
|
967
|
+
},
|
|
968
|
+
forApp(appName) {
|
|
969
|
+
return createValidatorAccessor(appName);
|
|
970
|
+
},
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
return {
|
|
975
|
+
services: createServiceAccessor(),
|
|
976
|
+
models: createModelAccessor(),
|
|
977
|
+
validators: createValidatorAccessor(),
|
|
978
|
+
servicesForApp: (appName) => createServiceAccessor(appName),
|
|
979
|
+
modelsForApp: (appName) => createModelAccessor(appName),
|
|
980
|
+
validatorsForApp: (appName) => createValidatorAccessor(appName),
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function buildRouteRuntimeContext({ context, layerAccessors, strictLayers, appDefinition = null }) {
|
|
985
|
+
const appName = appDefinition?.name || null;
|
|
986
|
+
|
|
987
|
+
if (!strictLayers) {
|
|
988
|
+
return {
|
|
989
|
+
...context,
|
|
990
|
+
app: appDefinition || null,
|
|
991
|
+
appName,
|
|
992
|
+
services: appName ? layerAccessors.servicesForApp(appName) : layerAccessors.services,
|
|
993
|
+
models: appName ? layerAccessors.modelsForApp(appName) : layerAccessors.models,
|
|
994
|
+
validators: appName ? layerAccessors.validatorsForApp(appName) : layerAccessors.validators,
|
|
995
|
+
env: context.env,
|
|
996
|
+
i18n: context.i18n,
|
|
997
|
+
mail: context.mail,
|
|
998
|
+
declaredAppNames: context.declaredAppNames,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
return {
|
|
1003
|
+
rootDir: context.rootDir,
|
|
1004
|
+
config: context.config,
|
|
1005
|
+
env: context.env,
|
|
1006
|
+
i18n: context.i18n,
|
|
1007
|
+
mail: context.mail,
|
|
1008
|
+
logger: context.logger,
|
|
1009
|
+
events: context.events,
|
|
1010
|
+
cache: context.cache,
|
|
1011
|
+
io: context.io,
|
|
1012
|
+
auth: context.auth,
|
|
1013
|
+
helpers: context.helpers,
|
|
1014
|
+
jlive: context.jlive,
|
|
1015
|
+
upload: context.upload,
|
|
1016
|
+
declaredAppNames: context.declaredAppNames,
|
|
1017
|
+
app: appDefinition || null,
|
|
1018
|
+
appName,
|
|
1019
|
+
services: appName ? layerAccessors.servicesForApp(appName) : layerAccessors.services,
|
|
1020
|
+
validators: appName ? layerAccessors.validatorsForApp(appName) : layerAccessors.validators,
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function bridgeRuntimeContextToRequest(req, runtimeContext = null, appName = null) {
|
|
1025
|
+
if (!req || !runtimeContext) {
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
req.aegis = req.aegis || {};
|
|
1030
|
+
|
|
1031
|
+
if (appName && typeof appName === 'string') {
|
|
1032
|
+
req.aegis.appName = appName;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const bindings = {
|
|
1036
|
+
config: runtimeContext.config,
|
|
1037
|
+
env: runtimeContext.env,
|
|
1038
|
+
logger: runtimeContext.logger,
|
|
1039
|
+
events: runtimeContext.events,
|
|
1040
|
+
cache: runtimeContext.cache,
|
|
1041
|
+
io: runtimeContext.io,
|
|
1042
|
+
auth: runtimeContext.auth,
|
|
1043
|
+
mail: runtimeContext.mail,
|
|
1044
|
+
helpers: runtimeContext.helpers,
|
|
1045
|
+
jlive: runtimeContext.jlive,
|
|
1046
|
+
upload: runtimeContext.upload,
|
|
1047
|
+
services: runtimeContext.services,
|
|
1048
|
+
models: runtimeContext.models,
|
|
1049
|
+
validators: runtimeContext.validators,
|
|
1050
|
+
database: runtimeContext.database,
|
|
1051
|
+
dbClient: runtimeContext.dbClient,
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
for (const [key, value] of Object.entries(bindings)) {
|
|
1055
|
+
if (typeof value !== 'undefined' && value !== null) {
|
|
1056
|
+
req.aegis[key] = value;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if (appName && req.aegis.services && req.aegis.validators) {
|
|
1061
|
+
req.aegis.app = {
|
|
1062
|
+
...(req.aegis.app || {}),
|
|
1063
|
+
name: appName,
|
|
1064
|
+
services: req.aegis.services,
|
|
1065
|
+
models: req.aegis.models,
|
|
1066
|
+
validators: req.aegis.validators,
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function buildHandlerContext(req, runtimeContext = null, currentApp = null) {
|
|
1072
|
+
const aegis = req?.aegis || {};
|
|
1073
|
+
const appName = currentApp || aegis.appName || runtimeContext?.appName || null;
|
|
1074
|
+
const services = aegis.services ?? runtimeContext?.services ?? null;
|
|
1075
|
+
const models = aegis.models ?? runtimeContext?.models ?? null;
|
|
1076
|
+
const validators = aegis.validators ?? runtimeContext?.validators ?? null;
|
|
1077
|
+
const resolveLayerByAppName = (accessor) => {
|
|
1078
|
+
if (!accessor || !appName || typeof accessor.get !== 'function') {
|
|
1079
|
+
return null;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
try {
|
|
1083
|
+
if (typeof accessor.has === 'function' && !accessor.has(appName)) {
|
|
1084
|
+
return null;
|
|
1085
|
+
}
|
|
1086
|
+
return accessor.get(appName);
|
|
1087
|
+
} catch {
|
|
1088
|
+
return null;
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
const service = resolveLayerByAppName(services);
|
|
1093
|
+
const model = resolveLayerByAppName(models);
|
|
1094
|
+
const validator = resolveLayerByAppName(validators);
|
|
1095
|
+
|
|
1096
|
+
return {
|
|
1097
|
+
appName,
|
|
1098
|
+
app: aegis.app || (appName
|
|
1099
|
+
? {
|
|
1100
|
+
name: appName,
|
|
1101
|
+
services,
|
|
1102
|
+
models,
|
|
1103
|
+
validators,
|
|
1104
|
+
}
|
|
1105
|
+
: null),
|
|
1106
|
+
config: aegis.config ?? runtimeContext?.config ?? null,
|
|
1107
|
+
env: aegis.env ?? runtimeContext?.env ?? null,
|
|
1108
|
+
i18n: aegis.i18n ?? runtimeContext?.i18n ?? null,
|
|
1109
|
+
mail: aegis.mail ?? runtimeContext?.mail ?? null,
|
|
1110
|
+
logger: aegis.logger ?? runtimeContext?.logger ?? null,
|
|
1111
|
+
events: aegis.events ?? runtimeContext?.events ?? null,
|
|
1112
|
+
cache: aegis.cache ?? runtimeContext?.cache ?? null,
|
|
1113
|
+
io: aegis.io ?? runtimeContext?.io ?? null,
|
|
1114
|
+
auth: aegis.auth ?? runtimeContext?.auth ?? null,
|
|
1115
|
+
helpers: aegis.helpers ?? runtimeContext?.helpers ?? null,
|
|
1116
|
+
jlive: aegis.jlive ?? runtimeContext?.jlive ?? null,
|
|
1117
|
+
upload: aegis.upload ?? runtimeContext?.upload ?? null,
|
|
1118
|
+
services,
|
|
1119
|
+
models,
|
|
1120
|
+
validators,
|
|
1121
|
+
service,
|
|
1122
|
+
model,
|
|
1123
|
+
validator,
|
|
1124
|
+
database: aegis.database ?? runtimeContext?.database ?? null,
|
|
1125
|
+
dbClient: aegis.dbClient ?? runtimeContext?.dbClient ?? null,
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function parseCookies(headerValue) {
|
|
1130
|
+
if (typeof headerValue !== 'string' || headerValue.length === 0) {
|
|
1131
|
+
return {};
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const parsed = {};
|
|
1135
|
+
const parts = headerValue.split(';');
|
|
1136
|
+
for (const part of parts) {
|
|
1137
|
+
const index = part.indexOf('=');
|
|
1138
|
+
if (index <= 0) {
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const key = part.slice(0, index).trim();
|
|
1143
|
+
const value = part.slice(index + 1).trim();
|
|
1144
|
+
if (!key) {
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
try {
|
|
1149
|
+
parsed[key] = decodeURIComponent(value);
|
|
1150
|
+
} catch {
|
|
1151
|
+
parsed[key] = value;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
return parsed;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function normalizeLocaleToken(locale, fallback = '') {
|
|
1159
|
+
if (typeof locale !== 'string' || locale.trim().length === 0) {
|
|
1160
|
+
return fallback;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
return locale.trim().replace(/_/g, '-').toLowerCase();
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function uniqueStringList(values) {
|
|
1167
|
+
const seen = new Set();
|
|
1168
|
+
const result = [];
|
|
1169
|
+
|
|
1170
|
+
for (const value of Array.isArray(values) ? values : []) {
|
|
1171
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
if (seen.has(value)) {
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
seen.add(value);
|
|
1180
|
+
result.push(value);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
return result;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function loadI18nJsonFile(filePath, logger, label = 'i18n translation file') {
|
|
1187
|
+
if (typeof filePath !== 'string' || filePath.trim().length === 0) {
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const resolvedPath = path.resolve(filePath.trim());
|
|
1192
|
+
|
|
1193
|
+
try {
|
|
1194
|
+
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
1195
|
+
const parsed = JSON.parse(content);
|
|
1196
|
+
|
|
1197
|
+
if (!isPlainObject(parsed)) {
|
|
1198
|
+
if (logger && typeof logger.warn === 'function') {
|
|
1199
|
+
logger.warn('%s must contain a JSON object: %s', label, resolvedPath);
|
|
1200
|
+
}
|
|
1201
|
+
return null;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
return parsed;
|
|
1205
|
+
} catch (error) {
|
|
1206
|
+
if (logger && typeof logger.warn === 'function') {
|
|
1207
|
+
logger.warn('%s could not be loaded (%s): %s', label, resolvedPath, error?.message || String(error));
|
|
1208
|
+
}
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function resolveI18nLocaleMessages(value, { rootDir, locale, logger }) {
|
|
1214
|
+
if (isPlainObject(value)) {
|
|
1215
|
+
return value;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
1219
|
+
return null;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const rawPath = value.trim();
|
|
1223
|
+
const filePath = path.isAbsolute(rawPath)
|
|
1224
|
+
? rawPath
|
|
1225
|
+
: path.join(rootDir || process.cwd(), rawPath);
|
|
1226
|
+
|
|
1227
|
+
return loadI18nJsonFile(filePath, logger, 'i18n.translations.' + locale);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function normalizeI18nConfig(rawI18n, rootDir = process.cwd(), logger = null) {
|
|
1231
|
+
const source = isPlainObject(rawI18n) ? rawI18n : {};
|
|
1232
|
+
const directTranslationsSource = isPlainObject(source.translations)
|
|
1233
|
+
? source.translations
|
|
1234
|
+
: (isPlainObject(source.locales)
|
|
1235
|
+
? source.locales
|
|
1236
|
+
: (isPlainObject(source.messages) ? source.messages : {}));
|
|
1237
|
+
|
|
1238
|
+
let fileTranslationsSource = {};
|
|
1239
|
+
if (typeof source.translationsFile === 'string' && source.translationsFile.trim().length > 0) {
|
|
1240
|
+
const rawPath = source.translationsFile.trim();
|
|
1241
|
+
const filePath = path.isAbsolute(rawPath)
|
|
1242
|
+
? rawPath
|
|
1243
|
+
: path.join(rootDir || process.cwd(), rawPath);
|
|
1244
|
+
|
|
1245
|
+
const loaded = loadI18nJsonFile(filePath, logger, 'i18n.translationsFile');
|
|
1246
|
+
if (isPlainObject(loaded)) {
|
|
1247
|
+
fileTranslationsSource = loaded;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const translationsSource = {
|
|
1252
|
+
...fileTranslationsSource,
|
|
1253
|
+
...directTranslationsSource,
|
|
1254
|
+
};
|
|
1255
|
+
|
|
1256
|
+
const translations = {};
|
|
1257
|
+
for (const [locale, value] of Object.entries(translationsSource)) {
|
|
1258
|
+
const normalizedLocale = normalizeLocaleToken(locale);
|
|
1259
|
+
if (!normalizedLocale) {
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const messages = resolveI18nLocaleMessages(value, {
|
|
1264
|
+
rootDir,
|
|
1265
|
+
locale: normalizedLocale,
|
|
1266
|
+
logger,
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
if (!isPlainObject(messages)) {
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
translations[normalizedLocale] = messages;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const translationLocales = Object.keys(translations);
|
|
1277
|
+
const configuredSupported = Array.isArray(source.supported)
|
|
1278
|
+
? source.supported
|
|
1279
|
+
.map((entry) => normalizeLocaleToken(entry))
|
|
1280
|
+
.filter(Boolean)
|
|
1281
|
+
: [];
|
|
1282
|
+
|
|
1283
|
+
const defaultLocale = normalizeLocaleToken(source.defaultLocale, 'en');
|
|
1284
|
+
const fallbackLocale = normalizeLocaleToken(source.fallbackLocale, defaultLocale);
|
|
1285
|
+
|
|
1286
|
+
const supported = uniqueStringList([
|
|
1287
|
+
...configuredSupported,
|
|
1288
|
+
...translationLocales,
|
|
1289
|
+
defaultLocale,
|
|
1290
|
+
fallbackLocale,
|
|
1291
|
+
]);
|
|
1292
|
+
|
|
1293
|
+
if (supported.length === 0) {
|
|
1294
|
+
supported.push(defaultLocale || 'en');
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
return {
|
|
1298
|
+
enabled: source.enabled === true || translationLocales.length > 0,
|
|
1299
|
+
defaultLocale: defaultLocale || 'en',
|
|
1300
|
+
fallbackLocale: fallbackLocale || defaultLocale || 'en',
|
|
1301
|
+
supported,
|
|
1302
|
+
queryParam: typeof source.queryParam === 'string' && source.queryParam.trim().length > 0
|
|
1303
|
+
? source.queryParam.trim()
|
|
1304
|
+
: 'lang',
|
|
1305
|
+
cookieName: typeof source.cookieName === 'string' && source.cookieName.trim().length > 0
|
|
1306
|
+
? source.cookieName.trim()
|
|
1307
|
+
: 'aegis_locale',
|
|
1308
|
+
detectFromHeader: source.detectFromHeader !== false,
|
|
1309
|
+
detectFromCookie: source.detectFromCookie !== false,
|
|
1310
|
+
detectFromQuery: source.detectFromQuery !== false,
|
|
1311
|
+
translations,
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function parseAcceptLanguage(headerValue) {
|
|
1316
|
+
if (typeof headerValue !== 'string' || headerValue.trim().length === 0) {
|
|
1317
|
+
return [];
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const weighted = [];
|
|
1321
|
+
for (const entry of headerValue.split(',')) {
|
|
1322
|
+
const [rawTag, ...params] = entry.trim().split(';');
|
|
1323
|
+
const tag = normalizeLocaleToken(rawTag);
|
|
1324
|
+
if (!tag) {
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
let quality = 1;
|
|
1329
|
+
for (const param of params) {
|
|
1330
|
+
const [rawKey, rawValue] = String(param || '').split('=');
|
|
1331
|
+
if (String(rawKey || '').trim().toLowerCase() !== 'q') {
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const parsed = Number.parseFloat(String(rawValue || '').trim());
|
|
1336
|
+
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) {
|
|
1337
|
+
quality = parsed;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
weighted.push({ tag, quality });
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
weighted.sort((left, right) => right.quality - left.quality);
|
|
1345
|
+
return weighted.map((entry) => entry.tag);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function resolveSupportedLocale(candidate, supportedLocales, fallbackLocale = 'en') {
|
|
1349
|
+
const supported = Array.isArray(supportedLocales)
|
|
1350
|
+
? supportedLocales
|
|
1351
|
+
.map((entry) => normalizeLocaleToken(entry))
|
|
1352
|
+
.filter(Boolean)
|
|
1353
|
+
: [];
|
|
1354
|
+
|
|
1355
|
+
if (supported.length === 0) {
|
|
1356
|
+
return normalizeLocaleToken(fallbackLocale, 'en');
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const normalizedCandidate = normalizeLocaleToken(candidate);
|
|
1360
|
+
if (!normalizedCandidate) {
|
|
1361
|
+
return resolveSupportedLocale(fallbackLocale, supported, supported[0]);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
if (normalizedCandidate === '*') {
|
|
1365
|
+
return resolveSupportedLocale(fallbackLocale, supported, supported[0]);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
if (supported.includes(normalizedCandidate)) {
|
|
1369
|
+
return normalizedCandidate;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const primary = normalizedCandidate.split('-')[0];
|
|
1373
|
+
const primaryMatch = supported.find((locale) => locale === primary || locale.startsWith(`${primary}-`));
|
|
1374
|
+
if (primaryMatch) {
|
|
1375
|
+
return primaryMatch;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
const fallback = normalizeLocaleToken(fallbackLocale);
|
|
1379
|
+
if (fallback && supported.includes(fallback)) {
|
|
1380
|
+
return fallback;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
const fallbackPrimary = fallback ? fallback.split('-')[0] : '';
|
|
1384
|
+
if (fallbackPrimary) {
|
|
1385
|
+
const fallbackPrimaryMatch = supported.find((locale) => locale === fallbackPrimary || locale.startsWith(`${fallbackPrimary}-`));
|
|
1386
|
+
if (fallbackPrimaryMatch) {
|
|
1387
|
+
return fallbackPrimaryMatch;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
return supported[0];
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function resolveRequestLocale(req, i18nConfig) {
|
|
1395
|
+
const fallbackLocale = i18nConfig.defaultLocale || 'en';
|
|
1396
|
+
const supported = i18nConfig.supported || [];
|
|
1397
|
+
|
|
1398
|
+
const useCandidate = (candidate, source) => {
|
|
1399
|
+
if (typeof candidate !== 'string' || candidate.trim().length === 0) {
|
|
1400
|
+
return null;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const resolved = resolveSupportedLocale(candidate, supported, fallbackLocale);
|
|
1404
|
+
if (resolved) {
|
|
1405
|
+
return { locale: resolved, source };
|
|
1406
|
+
}
|
|
1407
|
+
return null;
|
|
1408
|
+
};
|
|
1409
|
+
|
|
1410
|
+
if (i18nConfig.detectFromQuery && i18nConfig.queryParam && req?.query && typeof req.query === 'object') {
|
|
1411
|
+
const queryValue = req.query[i18nConfig.queryParam];
|
|
1412
|
+
const candidate = typeof queryValue === 'string'
|
|
1413
|
+
? queryValue
|
|
1414
|
+
: (Array.isArray(queryValue) && typeof queryValue[0] === 'string' ? queryValue[0] : '');
|
|
1415
|
+
|
|
1416
|
+
const resolved = useCandidate(candidate, 'query');
|
|
1417
|
+
if (resolved) {
|
|
1418
|
+
return resolved;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
if (i18nConfig.detectFromCookie && i18nConfig.cookieName) {
|
|
1423
|
+
const cookies = parseCookies(req?.headers?.cookie || '');
|
|
1424
|
+
const resolved = useCandidate(cookies[i18nConfig.cookieName], 'cookie');
|
|
1425
|
+
if (resolved) {
|
|
1426
|
+
return resolved;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
if (i18nConfig.detectFromHeader) {
|
|
1431
|
+
const accepted = parseAcceptLanguage(req?.headers?.['accept-language'] || '');
|
|
1432
|
+
for (const candidate of accepted) {
|
|
1433
|
+
const resolved = useCandidate(candidate, 'header');
|
|
1434
|
+
if (resolved) {
|
|
1435
|
+
return resolved;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
return {
|
|
1441
|
+
locale: resolveSupportedLocale(fallbackLocale, supported, fallbackLocale),
|
|
1442
|
+
source: 'default',
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function readTranslationValue(translations, key) {
|
|
1447
|
+
if (!isPlainObject(translations) || typeof key !== 'string' || key.trim().length === 0) {
|
|
1448
|
+
return undefined;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
const normalizedKey = key.trim();
|
|
1452
|
+
if (Object.prototype.hasOwnProperty.call(translations, normalizedKey)) {
|
|
1453
|
+
return translations[normalizedKey];
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
const parts = normalizedKey.split('.').filter(Boolean);
|
|
1457
|
+
if (parts.length === 0) {
|
|
1458
|
+
return undefined;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
let current = translations;
|
|
1462
|
+
for (const part of parts) {
|
|
1463
|
+
if (!isPlainObject(current) || !Object.prototype.hasOwnProperty.call(current, part)) {
|
|
1464
|
+
return undefined;
|
|
1465
|
+
}
|
|
1466
|
+
current = current[part];
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
return current;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function formatTranslationValue(value, variables = {}) {
|
|
1473
|
+
if (typeof value === 'string') {
|
|
1474
|
+
const safeVariables = isPlainObject(variables) ? variables : {};
|
|
1475
|
+
return value.replace(/\{([a-zA-Z0-9_.-]+)\}/g, (match, token) => {
|
|
1476
|
+
if (Object.prototype.hasOwnProperty.call(safeVariables, token)) {
|
|
1477
|
+
return String(safeVariables[token]);
|
|
1478
|
+
}
|
|
1479
|
+
return match;
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') {
|
|
1484
|
+
return String(value);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
return null;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
function resolveTranslationValue(i18nConfig, locale, key) {
|
|
1491
|
+
const normalizedLocale = normalizeLocaleToken(locale);
|
|
1492
|
+
if (!normalizedLocale || !isPlainObject(i18nConfig.translations)) {
|
|
1493
|
+
return undefined;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
const direct = readTranslationValue(i18nConfig.translations[normalizedLocale], key);
|
|
1497
|
+
if (direct !== undefined) {
|
|
1498
|
+
return direct;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
const primary = normalizedLocale.split('-')[0];
|
|
1502
|
+
if (!primary) {
|
|
1503
|
+
return undefined;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
for (const [candidateLocale, messages] of Object.entries(i18nConfig.translations)) {
|
|
1507
|
+
const normalizedCandidate = normalizeLocaleToken(candidateLocale);
|
|
1508
|
+
if (normalizedCandidate === primary || normalizedCandidate.startsWith(`${primary}-`)) {
|
|
1509
|
+
const resolved = readTranslationValue(messages, key);
|
|
1510
|
+
if (resolved !== undefined) {
|
|
1511
|
+
return resolved;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
return undefined;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
function createTranslator(i18nConfig, resolveActiveLocale) {
|
|
1520
|
+
return (key, variables = {}, options = {}) => {
|
|
1521
|
+
if (typeof key !== 'string' || key.trim().length === 0) {
|
|
1522
|
+
return '';
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
const normalizedKey = key.trim();
|
|
1526
|
+
const safeOptions = isPlainObject(options) ? options : {};
|
|
1527
|
+
const requestedLocale = resolveSupportedLocale(
|
|
1528
|
+
safeOptions.locale || resolveActiveLocale(),
|
|
1529
|
+
i18nConfig.supported,
|
|
1530
|
+
i18nConfig.defaultLocale,
|
|
1531
|
+
);
|
|
1532
|
+
|
|
1533
|
+
const localesToTry = uniqueStringList([
|
|
1534
|
+
requestedLocale,
|
|
1535
|
+
i18nConfig.fallbackLocale,
|
|
1536
|
+
i18nConfig.defaultLocale,
|
|
1537
|
+
]);
|
|
1538
|
+
|
|
1539
|
+
for (const locale of localesToTry) {
|
|
1540
|
+
const value = resolveTranslationValue(i18nConfig, locale, normalizedKey);
|
|
1541
|
+
if (value === undefined) {
|
|
1542
|
+
continue;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
const rendered = formatTranslationValue(value, variables);
|
|
1546
|
+
if (rendered !== null) {
|
|
1547
|
+
return rendered;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
return normalizedKey;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
if (typeof safeOptions.defaultValue === 'string') {
|
|
1554
|
+
return safeOptions.defaultValue;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
return normalizedKey;
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function createI18nBridge(i18nConfig, {
|
|
1562
|
+
resolveLocale = null,
|
|
1563
|
+
resolveLocaleSource = null,
|
|
1564
|
+
setLocale = null,
|
|
1565
|
+
} = {}) {
|
|
1566
|
+
const getActiveLocale = () => resolveSupportedLocale(
|
|
1567
|
+
typeof resolveLocale === 'function' ? resolveLocale() : i18nConfig.defaultLocale,
|
|
1568
|
+
i18nConfig.supported,
|
|
1569
|
+
i18nConfig.defaultLocale,
|
|
1570
|
+
);
|
|
1571
|
+
|
|
1572
|
+
const bridge = {
|
|
1573
|
+
enabled: i18nConfig.enabled,
|
|
1574
|
+
defaultLocale: i18nConfig.defaultLocale,
|
|
1575
|
+
fallbackLocale: i18nConfig.fallbackLocale,
|
|
1576
|
+
supported: [...i18nConfig.supported],
|
|
1577
|
+
queryParam: i18nConfig.queryParam,
|
|
1578
|
+
cookieName: i18nConfig.cookieName,
|
|
1579
|
+
resolveLocale(locale) {
|
|
1580
|
+
return resolveSupportedLocale(locale, i18nConfig.supported, i18nConfig.defaultLocale);
|
|
1581
|
+
},
|
|
1582
|
+
t: createTranslator(i18nConfig, getActiveLocale),
|
|
1583
|
+
forLocale(locale) {
|
|
1584
|
+
const resolvedLocale = resolveSupportedLocale(
|
|
1585
|
+
locale,
|
|
1586
|
+
i18nConfig.supported,
|
|
1587
|
+
i18nConfig.defaultLocale,
|
|
1588
|
+
);
|
|
1589
|
+
|
|
1590
|
+
return createI18nBridge(i18nConfig, {
|
|
1591
|
+
resolveLocale: () => resolvedLocale,
|
|
1592
|
+
});
|
|
1593
|
+
},
|
|
1594
|
+
};
|
|
1595
|
+
|
|
1596
|
+
Object.defineProperty(bridge, 'locale', {
|
|
1597
|
+
enumerable: true,
|
|
1598
|
+
get: () => getActiveLocale(),
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
Object.defineProperty(bridge, 'localeSource', {
|
|
1602
|
+
enumerable: true,
|
|
1603
|
+
get: () => {
|
|
1604
|
+
if (typeof resolveLocaleSource !== 'function') {
|
|
1605
|
+
return undefined;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
const source = resolveLocaleSource();
|
|
1609
|
+
return typeof source === 'string' && source.trim().length > 0 ? source : undefined;
|
|
1610
|
+
},
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
if (typeof setLocale === 'function') {
|
|
1614
|
+
bridge.setLocale = (nextLocale, options = {}) => setLocale(nextLocale, options);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
return bridge;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
function createRuntimeI18n(i18nConfig) {
|
|
1621
|
+
return createI18nBridge(i18nConfig, {
|
|
1622
|
+
resolveLocale: () => REQUEST_I18N_CONTEXT.getStore()?.locale || i18nConfig.defaultLocale,
|
|
1623
|
+
resolveLocaleSource: () => REQUEST_I18N_CONTEXT.getStore()?.source || 'default',
|
|
1624
|
+
setLocale: (nextLocale, options = {}) => {
|
|
1625
|
+
const store = REQUEST_I18N_CONTEXT.getStore();
|
|
1626
|
+
if (store && typeof store.setLocale === 'function') {
|
|
1627
|
+
return store.setLocale(nextLocale, options);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
return resolveSupportedLocale(nextLocale, i18nConfig.supported, i18nConfig.defaultLocale);
|
|
1631
|
+
},
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
function createRequestI18n(i18nConfig, req) {
|
|
1636
|
+
return createI18nBridge(i18nConfig, {
|
|
1637
|
+
resolveLocale: () => req?.aegis?.locale,
|
|
1638
|
+
resolveLocaleSource: () => req?.aegis?.localeSource,
|
|
1639
|
+
setLocale: (nextLocale, options = {}) => {
|
|
1640
|
+
if (typeof req?.aegis?.setLocale === 'function') {
|
|
1641
|
+
return req.aegis.setLocale(nextLocale, options);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
return resolveSupportedLocale(nextLocale, i18nConfig.supported, i18nConfig.defaultLocale);
|
|
1645
|
+
},
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
function isSafeHttpMethod(method) {
|
|
1651
|
+
const upper = String(method || '').toUpperCase();
|
|
1652
|
+
return upper === 'GET' || upper === 'HEAD' || upper === 'OPTIONS';
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function isFormSubmissionRequest(req) {
|
|
1656
|
+
const contentType = String(req.headers?.['content-type'] || '').toLowerCase();
|
|
1657
|
+
return contentType.includes('application/x-www-form-urlencoded')
|
|
1658
|
+
|| contentType.includes('multipart/form-data')
|
|
1659
|
+
|| contentType.includes('text/plain');
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function isJsonRequestContentType(contentType) {
|
|
1663
|
+
const normalized = String(contentType || '').toLowerCase();
|
|
1664
|
+
return normalized.includes('application/json') || normalized.includes('+json');
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
function hasRequestBody(req) {
|
|
1668
|
+
const contentLength = Number(req.headers?.['content-length']);
|
|
1669
|
+
if (Number.isFinite(contentLength) && contentLength > 0) {
|
|
1670
|
+
return true;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
return typeof req.headers?.['transfer-encoding'] === 'string';
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
function requestPathMatchesPrefix(requestPath, prefix) {
|
|
1677
|
+
if (prefix === '/') {
|
|
1678
|
+
return true;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
return requestPath === prefix || requestPath.startsWith(`${prefix}/`);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
function constantTimeEqual(left, right) {
|
|
1685
|
+
if (typeof left !== 'string' || typeof right !== 'string') {
|
|
1686
|
+
return false;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
const a = Buffer.from(left);
|
|
1690
|
+
const b = Buffer.from(right);
|
|
1691
|
+
if (a.length !== b.length) {
|
|
1692
|
+
return false;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
try {
|
|
1696
|
+
return crypto.timingSafeEqual(a, b);
|
|
1697
|
+
} catch {
|
|
1698
|
+
return false;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
function resolveSecureCookieFlag(req, secureSetting) {
|
|
1703
|
+
if (secureSetting === true || secureSetting === false) {
|
|
1704
|
+
return secureSetting;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// req.secure respects Express trust proxy settings and avoids trusting spoofed headers by default.
|
|
1708
|
+
return Boolean(req.secure);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
function resolveHttpsAssetPath(rootDir, filePath, label) {
|
|
1712
|
+
const normalizedPath = normalizeHttpsPathValue(filePath);
|
|
1713
|
+
if (!normalizedPath) {
|
|
1714
|
+
return '';
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
const resolvedPath = path.isAbsolute(normalizedPath)
|
|
1718
|
+
? normalizedPath
|
|
1719
|
+
: path.join(rootDir, normalizedPath);
|
|
1720
|
+
|
|
1721
|
+
if (!exists(resolvedPath)) {
|
|
1722
|
+
throw new Error(`HTTPS ${label} file not found: ${resolvedPath}`);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
return resolvedPath;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
function readHttpsAssetFile(rootDir, filePath, label) {
|
|
1729
|
+
const resolvedPath = resolveHttpsAssetPath(rootDir, filePath, label);
|
|
1730
|
+
if (!resolvedPath) {
|
|
1731
|
+
return null;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
try {
|
|
1735
|
+
return fs.readFileSync(resolvedPath);
|
|
1736
|
+
} catch (error) {
|
|
1737
|
+
throw new Error(`HTTPS ${label} file could not be read at ${resolvedPath}: ${error?.message || String(error)}`);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
function resolveHttpsAsset(rootDir, directValue, filePath, label) {
|
|
1742
|
+
const normalizedDirectValue = normalizeHttpsAssetValue(directValue);
|
|
1743
|
+
if (normalizedDirectValue) {
|
|
1744
|
+
return normalizedDirectValue;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
return readHttpsAssetFile(rootDir, filePath, label);
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
function resolveHttpsAssetList(rootDir, directValue, filePath, label) {
|
|
1751
|
+
const directList = normalizeHttpsAssetList(directValue);
|
|
1752
|
+
if (directList.length > 0) {
|
|
1753
|
+
return directList;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
const filePaths = normalizeHttpsPathList(filePath);
|
|
1757
|
+
if (filePaths.length === 0) {
|
|
1758
|
+
return [];
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
return filePaths.map((entry, index) => readHttpsAssetFile(rootDir, entry, `${label}[${index}]`));
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
function resolveServerProtocol(config) {
|
|
1765
|
+
return config?.https?.enabled === true ? 'https' : 'http';
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
function createHttpServer(expressApp, config) {
|
|
1769
|
+
const httpsConfig = normalizeHttpsConfig(config.https);
|
|
1770
|
+
config.https = httpsConfig;
|
|
1771
|
+
|
|
1772
|
+
if (!httpsConfig.enabled) {
|
|
1773
|
+
return {
|
|
1774
|
+
server: http.createServer(expressApp),
|
|
1775
|
+
protocol: 'http',
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
const rootDir = config.rootDir || process.cwd();
|
|
1780
|
+
const options = { ...httpsConfig.options };
|
|
1781
|
+
const pfx = resolveHttpsAsset(rootDir, httpsConfig.pfx, httpsConfig.pfxPath, 'pfx');
|
|
1782
|
+
const key = resolveHttpsAsset(rootDir, httpsConfig.key, httpsConfig.keyPath, 'key');
|
|
1783
|
+
const cert = resolveHttpsAsset(rootDir, httpsConfig.cert, httpsConfig.certPath, 'cert');
|
|
1784
|
+
const ca = resolveHttpsAssetList(rootDir, httpsConfig.ca, httpsConfig.caPath, 'ca');
|
|
1785
|
+
|
|
1786
|
+
if (!pfx && (!key || !cert)) {
|
|
1787
|
+
throw new Error('HTTPS requires either https.pfx/pfxPath or both https.key/keyPath and https.cert/certPath.');
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
if (pfx) {
|
|
1791
|
+
options.pfx = pfx;
|
|
1792
|
+
}
|
|
1793
|
+
if (key) {
|
|
1794
|
+
options.key = key;
|
|
1795
|
+
}
|
|
1796
|
+
if (cert) {
|
|
1797
|
+
options.cert = cert;
|
|
1798
|
+
}
|
|
1799
|
+
if (ca.length > 0) {
|
|
1800
|
+
options.ca = ca.length === 1 ? ca[0] : ca;
|
|
1801
|
+
}
|
|
1802
|
+
if (httpsConfig.passphrase) {
|
|
1803
|
+
options.passphrase = httpsConfig.passphrase;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
return {
|
|
1807
|
+
server: https.createServer(options, expressApp),
|
|
1808
|
+
protocol: 'https',
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
function extractCsrfToken(req, csrfConfig) {
|
|
1813
|
+
const fromHeader = req.headers?.[csrfConfig.headerName];
|
|
1814
|
+
if (typeof fromHeader === 'string' && fromHeader.length > 0) {
|
|
1815
|
+
return fromHeader;
|
|
1816
|
+
}
|
|
1817
|
+
if (Array.isArray(fromHeader) && fromHeader.length > 0) {
|
|
1818
|
+
return String(fromHeader[0]);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
if (req.body && typeof req.body === 'object') {
|
|
1822
|
+
const fromBody = req.body[csrfConfig.fieldName];
|
|
1823
|
+
if (typeof fromBody === 'string' && fromBody.length > 0) {
|
|
1824
|
+
return fromBody;
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
return '';
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
async function importModule(filePath) {
|
|
1832
|
+
const moduleUrl = `${pathToFileURL(filePath).href}?t=${Date.now()}`;
|
|
1833
|
+
return import(moduleUrl);
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
export function defineRoutes(register) {
|
|
1837
|
+
if (typeof register !== 'function') {
|
|
1838
|
+
throw new Error('defineRoutes requires a function.');
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
return {
|
|
1842
|
+
__aegisType: ROUTE_DEFINITION,
|
|
1843
|
+
register,
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
export function defineProjectRoutes({ apps = [], routes = null } = {}) {
|
|
1848
|
+
if (routes !== null && typeof routes !== 'function') {
|
|
1849
|
+
throw new Error('defineProjectRoutes.routes must be a function or null.');
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
return {
|
|
1853
|
+
__aegisType: PROJECT_ROUTE_DEFINITION,
|
|
1854
|
+
apps,
|
|
1855
|
+
routes,
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function getScopedLayerAccessor(accessor, appName) {
|
|
1860
|
+
if (!accessor) {
|
|
1861
|
+
return accessor;
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
if (appName && typeof accessor.forApp === 'function') {
|
|
1865
|
+
return accessor.forApp(appName);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
return accessor;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function buildControllerDependencies({ appName, runtimeContext = null, container }) {
|
|
1872
|
+
return {
|
|
1873
|
+
appName,
|
|
1874
|
+
rootDir: runtimeContext?.rootDir,
|
|
1875
|
+
config: runtimeContext?.config,
|
|
1876
|
+
env: runtimeContext?.env,
|
|
1877
|
+
i18n: runtimeContext?.i18n,
|
|
1878
|
+
mail: runtimeContext?.mail,
|
|
1879
|
+
logger: runtimeContext?.logger,
|
|
1880
|
+
events: runtimeContext?.events,
|
|
1881
|
+
cache: runtimeContext?.cache,
|
|
1882
|
+
io: runtimeContext?.io,
|
|
1883
|
+
auth: runtimeContext?.auth,
|
|
1884
|
+
helpers: runtimeContext?.helpers,
|
|
1885
|
+
jlive: runtimeContext?.jlive,
|
|
1886
|
+
upload: runtimeContext?.upload,
|
|
1887
|
+
services: getScopedLayerAccessor(runtimeContext?.services, appName),
|
|
1888
|
+
models: getScopedLayerAccessor(runtimeContext?.models, appName),
|
|
1889
|
+
validators: getScopedLayerAccessor(runtimeContext?.validators, appName),
|
|
1890
|
+
database: runtimeContext?.database,
|
|
1891
|
+
dbClient: runtimeContext?.dbClient,
|
|
1892
|
+
container: runtimeContext?.container || container,
|
|
1893
|
+
app: runtimeContext?.app || null,
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
function resolveControllerReference(reference, { container, currentApp, runtimeContext = null }) {
|
|
1898
|
+
if (typeof reference !== 'string' || reference.trim().length === 0) {
|
|
1899
|
+
throw new Error('Controller reference must be a non-empty string.');
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
const parts = reference.split('.').filter(Boolean);
|
|
1903
|
+
let appName = currentApp;
|
|
1904
|
+
let controllerName;
|
|
1905
|
+
let actionName;
|
|
1906
|
+
|
|
1907
|
+
if (parts.length >= 3) {
|
|
1908
|
+
[appName, controllerName, actionName] = parts;
|
|
1909
|
+
} else if (parts.length === 2) {
|
|
1910
|
+
[controllerName, actionName] = parts;
|
|
1911
|
+
} else if (parts.length === 1) {
|
|
1912
|
+
[controllerName] = parts;
|
|
1913
|
+
actionName = 'index';
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
if (!appName) {
|
|
1917
|
+
throw new Error(`Controller reference \"${reference}\" is missing app context.`);
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
const token = `controller:${appName}.${controllerName}`;
|
|
1921
|
+
let controller = container.getOrNull(token);
|
|
1922
|
+
|
|
1923
|
+
if (!controller) {
|
|
1924
|
+
throw new Error(`Controller not found for token ${token} (reference: ${reference})`);
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
if (
|
|
1928
|
+
isClassConstructor(controller)
|
|
1929
|
+
&& actionName
|
|
1930
|
+
&& typeof controller.prototype?.[actionName] === 'function'
|
|
1931
|
+
) {
|
|
1932
|
+
controller = new controller(buildControllerDependencies({
|
|
1933
|
+
appName,
|
|
1934
|
+
runtimeContext,
|
|
1935
|
+
container,
|
|
1936
|
+
}));
|
|
1937
|
+
container.set(token, controller);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
if (!actionName) {
|
|
1941
|
+
if (typeof controller === 'function') {
|
|
1942
|
+
return controller;
|
|
1943
|
+
}
|
|
1944
|
+
throw new Error(`Controller reference \"${reference}\" did not resolve to a function.`);
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
if (!controller || typeof controller[actionName] !== 'function') {
|
|
1948
|
+
throw new Error(`Action \"${actionName}\" not found on controller ${token}`);
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
return controller[actionName].bind(controller);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
function buildHandler(candidate, resolveRef, currentApp, runtimeContext = null) {
|
|
1955
|
+
if (typeof candidate === 'function') {
|
|
1956
|
+
return (req, res, next) => {
|
|
1957
|
+
try {
|
|
1958
|
+
bridgeRuntimeContextToRequest(req, runtimeContext, currentApp || runtimeContext?.appName || null);
|
|
1959
|
+
const handlerContext = buildHandlerContext(req, runtimeContext, currentApp);
|
|
1960
|
+
const result = candidate.length >= 4
|
|
1961
|
+
? candidate(handlerContext, req, res, next)
|
|
1962
|
+
: candidate(req, res, next);
|
|
1963
|
+
if (result && typeof result.then === 'function') {
|
|
1964
|
+
result.catch(next);
|
|
1965
|
+
}
|
|
1966
|
+
} catch (error) {
|
|
1967
|
+
next(error);
|
|
1968
|
+
}
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
if (typeof candidate === 'string') {
|
|
1973
|
+
return async (req, res, next) => {
|
|
1974
|
+
try {
|
|
1975
|
+
bridgeRuntimeContextToRequest(req, runtimeContext, currentApp || runtimeContext?.appName || null);
|
|
1976
|
+
const resolved = resolveRef(candidate, currentApp, runtimeContext);
|
|
1977
|
+
const handlerContext = buildHandlerContext(req, runtimeContext, currentApp);
|
|
1978
|
+
if (typeof resolved === 'function' && resolved.length >= 4) {
|
|
1979
|
+
await resolved(handlerContext, req, res, next);
|
|
1980
|
+
} else {
|
|
1981
|
+
await resolved(req, res, next);
|
|
1982
|
+
}
|
|
1983
|
+
} catch (error) {
|
|
1984
|
+
next(error);
|
|
1985
|
+
}
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
if (candidate && typeof candidate === 'object') {
|
|
1990
|
+
const register = candidate?.__aegisType === ROUTE_DEFINITION
|
|
1991
|
+
? candidate.register
|
|
1992
|
+
: candidate.register;
|
|
1993
|
+
|
|
1994
|
+
if (typeof register === 'function') {
|
|
1995
|
+
const candidateAppName = typeof candidate.appName === 'string' && candidate.appName.trim().length > 0
|
|
1996
|
+
? candidate.appName.trim()
|
|
1997
|
+
: null;
|
|
1998
|
+
const declaredAppNames = runtimeContext?.declaredAppNames instanceof Set
|
|
1999
|
+
? runtimeContext.declaredAppNames
|
|
2000
|
+
: null;
|
|
2001
|
+
|
|
2002
|
+
if (candidateAppName && declaredAppNames && !declaredAppNames.has(candidateAppName)) {
|
|
2003
|
+
throw new Error(
|
|
2004
|
+
`App "${candidateAppName}" is not declared in settings.apps. Declare it in settings before mounting routes.`,
|
|
2005
|
+
);
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
const nestedAppName = currentApp || candidateAppName || runtimeContext?.appName || null;
|
|
2009
|
+
const nestedRuntimeContext = runtimeContext && nestedAppName
|
|
2010
|
+
? {
|
|
2011
|
+
...runtimeContext,
|
|
2012
|
+
appName: nestedAppName,
|
|
2013
|
+
services: runtimeContext.services?.forApp
|
|
2014
|
+
? runtimeContext.services.forApp(nestedAppName)
|
|
2015
|
+
: runtimeContext.services,
|
|
2016
|
+
models: runtimeContext.models?.forApp
|
|
2017
|
+
? runtimeContext.models.forApp(nestedAppName)
|
|
2018
|
+
: runtimeContext.models,
|
|
2019
|
+
validators: runtimeContext.validators?.forApp
|
|
2020
|
+
? runtimeContext.validators.forApp(nestedAppName)
|
|
2021
|
+
: runtimeContext.validators,
|
|
2022
|
+
}
|
|
2023
|
+
: runtimeContext;
|
|
2024
|
+
let preparedRouter = null;
|
|
2025
|
+
let preparing = null;
|
|
2026
|
+
|
|
2027
|
+
const ensurePrepared = async () => {
|
|
2028
|
+
if (preparedRouter) {
|
|
2029
|
+
return preparedRouter;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
if (!preparing) {
|
|
2033
|
+
preparing = (async () => {
|
|
2034
|
+
const nestedRouter = express.Router();
|
|
2035
|
+
const nestedRouteApi = createRouteApi(
|
|
2036
|
+
nestedRouter,
|
|
2037
|
+
resolveRef,
|
|
2038
|
+
nestedAppName,
|
|
2039
|
+
{ runtimeContext: nestedRuntimeContext },
|
|
2040
|
+
);
|
|
2041
|
+
await register(nestedRouteApi, EMPTY_ROUTE_CONTEXT);
|
|
2042
|
+
preparedRouter = nestedRouter;
|
|
2043
|
+
return preparedRouter;
|
|
2044
|
+
})().catch((error) => {
|
|
2045
|
+
preparing = null;
|
|
2046
|
+
throw error;
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
return preparing;
|
|
2051
|
+
};
|
|
2052
|
+
|
|
2053
|
+
return async (req, res, next) => {
|
|
2054
|
+
try {
|
|
2055
|
+
bridgeRuntimeContextToRequest(req, nestedRuntimeContext, nestedAppName);
|
|
2056
|
+
const nestedRouter = await ensurePrepared();
|
|
2057
|
+
nestedRouter(req, res, next);
|
|
2058
|
+
} catch (error) {
|
|
2059
|
+
next(error);
|
|
2060
|
+
}
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
throw new Error('Route handler must be a function, a controller reference string, or a route module with register().');
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
function createDisabledUploadApi() {
|
|
2069
|
+
const disabled = () => {
|
|
2070
|
+
throw new Error('Uploads are disabled. Enable settings.uploads.enabled=true to use route.upload middleware.');
|
|
2071
|
+
};
|
|
2072
|
+
|
|
2073
|
+
return {
|
|
2074
|
+
single: disabled,
|
|
2075
|
+
array: disabled,
|
|
2076
|
+
fields: disabled,
|
|
2077
|
+
any: disabled,
|
|
2078
|
+
none: disabled,
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
function createRouteApi(router, resolveRef, currentApp, options = {}) {
|
|
2083
|
+
const routeState = options?.routeState || null;
|
|
2084
|
+
const runtimeContext = options?.runtimeContext || null;
|
|
2085
|
+
const api = {};
|
|
2086
|
+
api.upload = runtimeContext?.upload || createDisabledUploadApi();
|
|
2087
|
+
|
|
2088
|
+
const verbs = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'all'];
|
|
2089
|
+
for (const verb of verbs) {
|
|
2090
|
+
api[verb] = (routePath, ...handlers) => {
|
|
2091
|
+
if (typeof routePath !== 'string' || !routePath.startsWith('/')) {
|
|
2092
|
+
throw new Error(`Invalid route path \"${routePath}\". It must start with /`);
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
if (handlers.length === 0) {
|
|
2096
|
+
throw new Error(`Route ${verb.toUpperCase()} ${routePath} requires at least one handler.`);
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
const chain = handlers.map((entry) => buildHandler(entry, resolveRef, currentApp, runtimeContext));
|
|
2100
|
+
router[verb](routePath, ...chain);
|
|
2101
|
+
if (routeState) {
|
|
2102
|
+
routeState.hasAny = true;
|
|
2103
|
+
const canonicalPath = typeof routePath === 'string' ? routePath.trim() : routePath;
|
|
2104
|
+
if (verb === 'get' && canonicalPath === '/') {
|
|
2105
|
+
routeState.hasRootGet = true;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
return api;
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
api.use = (pathOrHandler, ...handlers) => {
|
|
2113
|
+
if (typeof pathOrHandler === 'string') {
|
|
2114
|
+
const mapped = handlers.map((handler) => buildHandler(handler, resolveRef, currentApp, runtimeContext));
|
|
2115
|
+
router.use(pathOrHandler, ...mapped);
|
|
2116
|
+
if (routeState) {
|
|
2117
|
+
routeState.hasAny = true;
|
|
2118
|
+
}
|
|
2119
|
+
return api;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
const mapped = [pathOrHandler, ...handlers].map((handler) => buildHandler(handler, resolveRef, currentApp, runtimeContext));
|
|
2123
|
+
router.use(...mapped);
|
|
2124
|
+
if (routeState) {
|
|
2125
|
+
routeState.hasAny = true;
|
|
2126
|
+
}
|
|
2127
|
+
return api;
|
|
2128
|
+
};
|
|
2129
|
+
|
|
2130
|
+
return api;
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
function attachRequestRuntimeBridge(expressApp, runtimeContext = null) {
|
|
2134
|
+
const helperSet = isPlainObject(runtimeContext?.helpers) ? runtimeContext.helpers : {};
|
|
2135
|
+
const jliveBridge = runtimeContext?.jlive || null;
|
|
2136
|
+
const runtimeEnv = isPlainObject(runtimeContext?.env) ? runtimeContext.env : {};
|
|
2137
|
+
const authManager = runtimeContext?.auth || null;
|
|
2138
|
+
const mailManager = runtimeContext?.mail || null;
|
|
2139
|
+
const uploadManager = runtimeContext?.upload || null;
|
|
2140
|
+
const services = runtimeContext?.services || null;
|
|
2141
|
+
const models = runtimeContext?.models || null;
|
|
2142
|
+
const validators = runtimeContext?.validators || null;
|
|
2143
|
+
const cache = runtimeContext?.cache || null;
|
|
2144
|
+
const events = runtimeContext?.events || null;
|
|
2145
|
+
const config = runtimeContext?.config || null;
|
|
2146
|
+
const logger = runtimeContext?.logger || null;
|
|
2147
|
+
const io = runtimeContext?.io || null;
|
|
2148
|
+
const database = runtimeContext?.database || null;
|
|
2149
|
+
const dbClient = runtimeContext?.dbClient ?? runtimeContext?.database?.client ?? null;
|
|
2150
|
+
const i18nConfig = normalizeI18nConfig(
|
|
2151
|
+
config?.i18n,
|
|
2152
|
+
runtimeContext?.rootDir || config?.rootDir || process.cwd(),
|
|
2153
|
+
logger,
|
|
2154
|
+
);
|
|
2155
|
+
|
|
2156
|
+
if (config && isPlainObject(config)) {
|
|
2157
|
+
config.i18n = i18nConfig;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
expressApp.use((req, res, next) => {
|
|
2161
|
+
REQUEST_I18N_CONTEXT.run({}, () => {
|
|
2162
|
+
const requestI18nContext = REQUEST_I18N_CONTEXT.getStore();
|
|
2163
|
+
const syncRequestI18nContext = (fallbackSource = 'default') => {
|
|
2164
|
+
if (!requestI18nContext) {
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
requestI18nContext.locale = resolveSupportedLocale(
|
|
2169
|
+
req.aegis?.locale,
|
|
2170
|
+
i18nConfig.supported,
|
|
2171
|
+
i18nConfig.defaultLocale,
|
|
2172
|
+
);
|
|
2173
|
+
requestI18nContext.source = typeof req.aegis?.localeSource === 'string' && req.aegis.localeSource.trim().length > 0
|
|
2174
|
+
? req.aegis.localeSource
|
|
2175
|
+
: fallbackSource;
|
|
2176
|
+
requestI18nContext.setLocale = typeof req.aegis?.setLocale === 'function'
|
|
2177
|
+
? req.aegis.setLocale
|
|
2178
|
+
: null;
|
|
2179
|
+
};
|
|
2180
|
+
|
|
2181
|
+
req.aegis = req.aegis || {};
|
|
2182
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'helpers')) {
|
|
2183
|
+
req.aegis.helpers = helperSet;
|
|
2184
|
+
}
|
|
2185
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'jlive')) {
|
|
2186
|
+
req.aegis.jlive = jliveBridge;
|
|
2187
|
+
}
|
|
2188
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'env')) {
|
|
2189
|
+
req.aegis.env = runtimeEnv;
|
|
2190
|
+
}
|
|
2191
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'auth')) {
|
|
2192
|
+
req.aegis.auth = authManager;
|
|
2193
|
+
}
|
|
2194
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'mail')) {
|
|
2195
|
+
req.aegis.mail = mailManager;
|
|
2196
|
+
}
|
|
2197
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'upload')) {
|
|
2198
|
+
req.aegis.upload = uploadManager;
|
|
2199
|
+
}
|
|
2200
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'services')) {
|
|
2201
|
+
req.aegis.services = services;
|
|
2202
|
+
}
|
|
2203
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'models')) {
|
|
2204
|
+
req.aegis.models = models;
|
|
2205
|
+
}
|
|
2206
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'validators')) {
|
|
2207
|
+
req.aegis.validators = validators;
|
|
2208
|
+
}
|
|
2209
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'cache')) {
|
|
2210
|
+
req.aegis.cache = cache;
|
|
2211
|
+
}
|
|
2212
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'events')) {
|
|
2213
|
+
req.aegis.events = events;
|
|
2214
|
+
}
|
|
2215
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'config')) {
|
|
2216
|
+
req.aegis.config = config;
|
|
2217
|
+
}
|
|
2218
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'logger')) {
|
|
2219
|
+
req.aegis.logger = logger;
|
|
2220
|
+
}
|
|
2221
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'io')) {
|
|
2222
|
+
req.aegis.io = io;
|
|
2223
|
+
}
|
|
2224
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'database')) {
|
|
2225
|
+
req.aegis.database = database;
|
|
2226
|
+
}
|
|
2227
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'dbClient')) {
|
|
2228
|
+
req.aegis.dbClient = dbClient;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
const localeResolution = i18nConfig.enabled
|
|
2232
|
+
? resolveRequestLocale(req, i18nConfig)
|
|
2233
|
+
: {
|
|
2234
|
+
locale: i18nConfig.defaultLocale,
|
|
2235
|
+
source: 'disabled',
|
|
2236
|
+
};
|
|
2237
|
+
|
|
2238
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'locale')) {
|
|
2239
|
+
req.aegis.locale = localeResolution.locale;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'localeSource')) {
|
|
2243
|
+
req.aegis.localeSource = localeResolution.source;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'setLocale')) {
|
|
2247
|
+
req.aegis.setLocale = (nextLocale, options = {}) => {
|
|
2248
|
+
const safeOptions = isPlainObject(options) ? options : {};
|
|
2249
|
+
const resolvedLocale = resolveSupportedLocale(
|
|
2250
|
+
nextLocale,
|
|
2251
|
+
i18nConfig.supported,
|
|
2252
|
+
i18nConfig.defaultLocale,
|
|
2253
|
+
);
|
|
2254
|
+
|
|
2255
|
+
req.aegis.locale = resolvedLocale;
|
|
2256
|
+
req.aegis.localeSource = 'manual';
|
|
2257
|
+
|
|
2258
|
+
const shouldPersist = safeOptions.persist !== false;
|
|
2259
|
+
if (shouldPersist && i18nConfig.cookieName && typeof res.cookie === 'function') {
|
|
2260
|
+
res.cookie(i18nConfig.cookieName, resolvedLocale, {
|
|
2261
|
+
path: '/',
|
|
2262
|
+
sameSite: 'lax',
|
|
2263
|
+
httpOnly: false,
|
|
2264
|
+
});
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
syncRequestI18nContext('manual');
|
|
2268
|
+
return req.aegis.locale;
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 'i18n')) {
|
|
2273
|
+
req.aegis.i18n = createRequestI18n(i18nConfig, req);
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
if (!Object.prototype.hasOwnProperty.call(req.aegis, 't')) {
|
|
2277
|
+
req.aegis.t = typeof req.aegis.i18n?.t === 'function'
|
|
2278
|
+
? req.aegis.i18n.t
|
|
2279
|
+
: createTranslator(
|
|
2280
|
+
i18nConfig,
|
|
2281
|
+
() => resolveSupportedLocale(req.aegis.locale, i18nConfig.supported, i18nConfig.defaultLocale),
|
|
2282
|
+
);
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
syncRequestI18nContext(localeResolution.source);
|
|
2286
|
+
|
|
2287
|
+
if (
|
|
2288
|
+
i18nConfig.enabled
|
|
2289
|
+
&& localeResolution.source === 'query'
|
|
2290
|
+
&& i18nConfig.cookieName
|
|
2291
|
+
&& typeof res.cookie === 'function'
|
|
2292
|
+
) {
|
|
2293
|
+
res.cookie(i18nConfig.cookieName, localeResolution.locale, {
|
|
2294
|
+
path: '/',
|
|
2295
|
+
sameSite: 'lax',
|
|
2296
|
+
httpOnly: false,
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
next();
|
|
2301
|
+
});
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
async function loadDefaultInstallTemplate(logger) {
|
|
2306
|
+
try {
|
|
2307
|
+
return await fsPromises.readFile(DEFAULT_INSTALL_TEMPLATE_PATH, 'utf8');
|
|
2308
|
+
} catch (error) {
|
|
2309
|
+
logger.error('Unable to load default install template: %s', error?.message || String(error));
|
|
2310
|
+
throw error;
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
async function loadDefaultMaintenanceTemplate(logger) {
|
|
2315
|
+
try {
|
|
2316
|
+
return await fsPromises.readFile(DEFAULT_MAINTENANCE_TEMPLATE_PATH, 'utf8');
|
|
2317
|
+
} catch (error) {
|
|
2318
|
+
logger.error('Unable to load default maintenance template: %s', error?.message || String(error));
|
|
2319
|
+
throw error;
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
function renderDefaultInstallPage(template, config) {
|
|
2324
|
+
return ejs.render(template, {
|
|
2325
|
+
appName: config.appName || 'AegisNode',
|
|
2326
|
+
env: config.env || 'development',
|
|
2327
|
+
createAppCommand: 'aegisnode createapp users',
|
|
2328
|
+
nowYear: new Date().getFullYear(),
|
|
2329
|
+
});
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
function renderDefaultMaintenancePage(template, config, maintenanceConfig, requestPath) {
|
|
2333
|
+
return ejs.render(template, {
|
|
2334
|
+
appName: config.appName || 'AegisNode',
|
|
2335
|
+
env: config.env || 'development',
|
|
2336
|
+
requestPath: requestPath || '/',
|
|
2337
|
+
retryAfter: maintenanceConfig.retryAfter,
|
|
2338
|
+
statusCode: maintenanceConfig.statusCode,
|
|
2339
|
+
nowYear: new Date().getFullYear(),
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
async function loadDirectoryFiles(directoryPath) {
|
|
2344
|
+
if (!exists(directoryPath)) {
|
|
2345
|
+
return [];
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
const entries = await fsPromises.readdir(directoryPath, { withFileTypes: true });
|
|
2349
|
+
return entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
async function registerControllers({ appName, appRoot, container, logger }) {
|
|
2353
|
+
const sourceDirs = ['controllers', 'views'];
|
|
2354
|
+
|
|
2355
|
+
for (const sourceDir of sourceDirs) {
|
|
2356
|
+
const directory = path.join(appRoot, sourceDir);
|
|
2357
|
+
const files = await loadDirectoryFiles(directory);
|
|
2358
|
+
|
|
2359
|
+
for (const fileName of files) {
|
|
2360
|
+
if (!fileName.endsWith('.js')) {
|
|
2361
|
+
continue;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
const filePath = path.join(directory, fileName);
|
|
2365
|
+
const loaded = await importModule(filePath);
|
|
2366
|
+
const controller = loaded.default ?? loaded;
|
|
2367
|
+
const controllerName = normalizeControllerName(fileName);
|
|
2368
|
+
container.set(`controller:${appName}.${controllerName}`, controller);
|
|
2369
|
+
logger.debug('Controller registered: controller:%s.%s', appName, controllerName);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
const singleFiles = ['controllers.js', 'views.js'];
|
|
2374
|
+
for (const fileName of singleFiles) {
|
|
2375
|
+
const filePath = path.join(appRoot, fileName);
|
|
2376
|
+
if (!exists(filePath)) {
|
|
2377
|
+
continue;
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
const loaded = await importModule(filePath);
|
|
2381
|
+
const controller = loaded.default ?? loaded;
|
|
2382
|
+
const controllerName = normalizeControllerName(fileName);
|
|
2383
|
+
container.set(`controller:${appName}.${controllerName}`, controller);
|
|
2384
|
+
logger.debug('Controller registered: controller:%s.%s', appName, controllerName);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
async function registerModels({ appName, appRoot, container, logger }) {
|
|
2389
|
+
const modelsFile = path.join(appRoot, 'models.js');
|
|
2390
|
+
if (exists(modelsFile)) {
|
|
2391
|
+
const loaded = await importModule(modelsFile);
|
|
2392
|
+
const exported = loaded.default ?? loaded;
|
|
2393
|
+
|
|
2394
|
+
container.set(`model:${appName}`, exported);
|
|
2395
|
+
logger.debug('Models registered: model:%s', appName);
|
|
2396
|
+
|
|
2397
|
+
if (exported && typeof exported === 'object') {
|
|
2398
|
+
for (const [key, value] of Object.entries(exported)) {
|
|
2399
|
+
container.set(`model:${appName}.${key}`, value);
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
const rootFiles = await loadDirectoryFiles(appRoot);
|
|
2405
|
+
for (const fileName of rootFiles) {
|
|
2406
|
+
if (!fileName.endsWith('.model.js')) {
|
|
2407
|
+
continue;
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
const filePath = path.join(appRoot, fileName);
|
|
2411
|
+
const loaded = await importModule(filePath);
|
|
2412
|
+
const model = loaded.default ?? loaded;
|
|
2413
|
+
const modelName = normalizeModelName(fileName);
|
|
2414
|
+
container.set(`model:${appName}.${modelName}`, model);
|
|
2415
|
+
logger.debug('Model registered: model:%s.%s', appName, modelName);
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
const modelsDir = path.join(appRoot, 'models');
|
|
2419
|
+
const files = await loadDirectoryFiles(modelsDir);
|
|
2420
|
+
|
|
2421
|
+
for (const fileName of files) {
|
|
2422
|
+
if (!fileName.endsWith('.js')) {
|
|
2423
|
+
continue;
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
const filePath = path.join(modelsDir, fileName);
|
|
2427
|
+
const loaded = await importModule(filePath);
|
|
2428
|
+
const model = loaded.default ?? loaded;
|
|
2429
|
+
const modelName = normalizeModelName(fileName);
|
|
2430
|
+
container.set(`model:${appName}.${modelName}`, model);
|
|
2431
|
+
logger.debug('Model registered: model:%s.%s', appName, modelName);
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
async function registerValidators({ appName, appRoot, container, logger }) {
|
|
2436
|
+
const validatorsFile = path.join(appRoot, 'validators.js');
|
|
2437
|
+
if (exists(validatorsFile)) {
|
|
2438
|
+
const loaded = await importModule(validatorsFile);
|
|
2439
|
+
const exported = loaded.default ?? loaded;
|
|
2440
|
+
|
|
2441
|
+
container.set(`validator:${appName}`, exported);
|
|
2442
|
+
logger.debug('Validators registered: validator:%s', appName);
|
|
2443
|
+
|
|
2444
|
+
if (exported && typeof exported === 'object') {
|
|
2445
|
+
for (const [key, value] of Object.entries(exported)) {
|
|
2446
|
+
container.set(`validator:${appName}.${key}`, value);
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
const rootFiles = await loadDirectoryFiles(appRoot);
|
|
2452
|
+
for (const fileName of rootFiles) {
|
|
2453
|
+
if (!fileName.endsWith('.validator.js')) {
|
|
2454
|
+
continue;
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
const filePath = path.join(appRoot, fileName);
|
|
2458
|
+
const loaded = await importModule(filePath);
|
|
2459
|
+
const validator = loaded.default ?? loaded;
|
|
2460
|
+
const validatorName = normalizeValidatorName(fileName);
|
|
2461
|
+
container.set(`validator:${appName}.${validatorName}`, validator);
|
|
2462
|
+
logger.debug('Validator registered: validator:%s.%s', appName, validatorName);
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
const validatorsDir = path.join(appRoot, 'validators');
|
|
2466
|
+
const files = await loadDirectoryFiles(validatorsDir);
|
|
2467
|
+
|
|
2468
|
+
for (const fileName of files) {
|
|
2469
|
+
if (!fileName.endsWith('.js')) {
|
|
2470
|
+
continue;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
const filePath = path.join(validatorsDir, fileName);
|
|
2474
|
+
const loaded = await importModule(filePath);
|
|
2475
|
+
const validator = loaded.default ?? loaded;
|
|
2476
|
+
const validatorName = normalizeValidatorName(fileName);
|
|
2477
|
+
container.set(`validator:${appName}.${validatorName}`, validator);
|
|
2478
|
+
logger.debug('Validator registered: validator:%s.%s', appName, validatorName);
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
async function registerServices({ appName, appRoot, container, logger }) {
|
|
2483
|
+
const servicesFile = path.join(appRoot, 'services.js');
|
|
2484
|
+
if (exists(servicesFile)) {
|
|
2485
|
+
const loaded = await importModule(servicesFile);
|
|
2486
|
+
const exported = loaded.default ?? loaded;
|
|
2487
|
+
|
|
2488
|
+
container.set(`service:${appName}`, exported);
|
|
2489
|
+
logger.debug('Service registered: service:%s', appName);
|
|
2490
|
+
|
|
2491
|
+
if (exported && typeof exported === 'object') {
|
|
2492
|
+
for (const [key, value] of Object.entries(exported)) {
|
|
2493
|
+
container.set(`service:${appName}.${key}`, value);
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
const servicesDir = path.join(appRoot, 'services');
|
|
2499
|
+
const files = await loadDirectoryFiles(servicesDir);
|
|
2500
|
+
|
|
2501
|
+
for (const fileName of files) {
|
|
2502
|
+
if (!fileName.endsWith('.js')) {
|
|
2503
|
+
continue;
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
const filePath = path.join(servicesDir, fileName);
|
|
2507
|
+
const loaded = await importModule(filePath);
|
|
2508
|
+
const service = loaded.default ?? loaded;
|
|
2509
|
+
const serviceName = normalizeServiceName(fileName);
|
|
2510
|
+
container.set(`service:${appName}.${serviceName}`, service);
|
|
2511
|
+
logger.debug('Service registered: service:%s.%s', appName, serviceName);
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
async function registerSubscribers({ appName, appRoot, context, logger }) {
|
|
2516
|
+
const subscribersFile = exists(path.join(appRoot, 'subscribers.js'))
|
|
2517
|
+
? path.join(appRoot, 'subscribers.js')
|
|
2518
|
+
: path.join(appRoot, 'subscribers', 'index.js');
|
|
2519
|
+
|
|
2520
|
+
if (!exists(subscribersFile)) {
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
const loaded = await importModule(subscribersFile);
|
|
2525
|
+
const register = loaded.default ?? loaded;
|
|
2526
|
+
|
|
2527
|
+
if (typeof register === 'function') {
|
|
2528
|
+
await register({ ...context, appName });
|
|
2529
|
+
logger.debug('Subscribers loaded for app %s', appName);
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
async function mountAppRoutes({ appDefinition, appRoot, context, expressApp, routeContext = null }) {
|
|
2534
|
+
const routesFile = exists(path.join(appRoot, 'routes.js'))
|
|
2535
|
+
? path.join(appRoot, 'routes.js')
|
|
2536
|
+
: path.join(appRoot, 'routes', 'index.js');
|
|
2537
|
+
if (!exists(routesFile)) {
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
const loaded = await importModule(routesFile);
|
|
2542
|
+
const exported = loaded.default ?? loaded;
|
|
2543
|
+
const effectiveRouteContext = routeContext || { ...context, app: appDefinition };
|
|
2544
|
+
const appServices = effectiveRouteContext.services?.forApp
|
|
2545
|
+
? effectiveRouteContext.services.forApp(appDefinition.name)
|
|
2546
|
+
: effectiveRouteContext.services;
|
|
2547
|
+
const appModels = effectiveRouteContext.models?.forApp
|
|
2548
|
+
? effectiveRouteContext.models.forApp(appDefinition.name)
|
|
2549
|
+
: effectiveRouteContext.models;
|
|
2550
|
+
const appValidators = effectiveRouteContext.validators?.forApp
|
|
2551
|
+
? effectiveRouteContext.validators.forApp(appDefinition.name)
|
|
2552
|
+
: effectiveRouteContext.validators;
|
|
2553
|
+
|
|
2554
|
+
const router = express.Router();
|
|
2555
|
+
router.use((req, res, next) => {
|
|
2556
|
+
req.aegis = req.aegis || {};
|
|
2557
|
+
req.aegis.appName = appDefinition.name;
|
|
2558
|
+
req.aegis.app = {
|
|
2559
|
+
name: appDefinition.name,
|
|
2560
|
+
mount: appDefinition.mount,
|
|
2561
|
+
services: appServices,
|
|
2562
|
+
models: appModels,
|
|
2563
|
+
validators: appValidators,
|
|
2564
|
+
};
|
|
2565
|
+
req.aegis.services = appServices;
|
|
2566
|
+
req.aegis.models = appModels;
|
|
2567
|
+
req.aegis.validators = appValidators;
|
|
2568
|
+
next();
|
|
2569
|
+
});
|
|
2570
|
+
const routeApi = createRouteApi(
|
|
2571
|
+
router,
|
|
2572
|
+
(reference, currentApp, runtimeContext) => resolveControllerReference(reference, {
|
|
2573
|
+
container: context.container,
|
|
2574
|
+
currentApp,
|
|
2575
|
+
runtimeContext,
|
|
2576
|
+
}),
|
|
2577
|
+
appDefinition.name,
|
|
2578
|
+
{ runtimeContext: effectiveRouteContext },
|
|
2579
|
+
);
|
|
2580
|
+
|
|
2581
|
+
if (exported?.__aegisType === ROUTE_DEFINITION) {
|
|
2582
|
+
await exported.register(routeApi, EMPTY_ROUTE_CONTEXT);
|
|
2583
|
+
} else if (exported && typeof exported === 'object' && typeof exported.register === 'function') {
|
|
2584
|
+
await exported.register(routeApi, EMPTY_ROUTE_CONTEXT);
|
|
2585
|
+
} else if (typeof exported === 'function') {
|
|
2586
|
+
const result = await exported(routeApi, EMPTY_ROUTE_CONTEXT);
|
|
2587
|
+
if (isRouterInstance(result)) {
|
|
2588
|
+
expressApp.use(appDefinition.mount, result);
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2591
|
+
} else if (isRouterInstance(exported)) {
|
|
2592
|
+
expressApp.use(appDefinition.mount, exported);
|
|
2593
|
+
return;
|
|
2594
|
+
} else {
|
|
2595
|
+
throw new Error(`Unsupported routes export for app ${appDefinition.name}`);
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
expressApp.use(appDefinition.mount, router);
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
async function loadProjectRoutes(rootDir) {
|
|
2602
|
+
const routesFile = exists(path.join(rootDir, 'routes.js'))
|
|
2603
|
+
? path.join(rootDir, 'routes.js')
|
|
2604
|
+
: path.join(rootDir, 'routes', 'index.js');
|
|
2605
|
+
|
|
2606
|
+
if (!exists(routesFile)) {
|
|
2607
|
+
return null;
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
const loaded = await importModule(routesFile);
|
|
2611
|
+
const exported = loaded.default ?? loaded;
|
|
2612
|
+
|
|
2613
|
+
if (exported?.__aegisType === PROJECT_ROUTE_DEFINITION) {
|
|
2614
|
+
return {
|
|
2615
|
+
...exported,
|
|
2616
|
+
sourceFile: routesFile,
|
|
2617
|
+
};
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
if (exported?.__aegisType === ROUTE_DEFINITION) {
|
|
2621
|
+
return {
|
|
2622
|
+
__aegisType: PROJECT_ROUTE_DEFINITION,
|
|
2623
|
+
apps: [],
|
|
2624
|
+
routes: exported.register,
|
|
2625
|
+
sourceFile: routesFile,
|
|
2626
|
+
};
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
if (typeof exported === 'function') {
|
|
2630
|
+
return {
|
|
2631
|
+
__aegisType: PROJECT_ROUTE_DEFINITION,
|
|
2632
|
+
apps: [],
|
|
2633
|
+
routes: exported,
|
|
2634
|
+
sourceFile: routesFile,
|
|
2635
|
+
};
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
if (exported && typeof exported === 'object' && typeof exported.register === 'function') {
|
|
2639
|
+
return {
|
|
2640
|
+
__aegisType: PROJECT_ROUTE_DEFINITION,
|
|
2641
|
+
apps: [],
|
|
2642
|
+
routes: exported.register,
|
|
2643
|
+
sourceFile: routesFile,
|
|
2644
|
+
};
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
return null;
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
function configureTemplateEngine(expressApp, config, rootDir, logger) {
|
|
2651
|
+
const templateConfig = normalizeTemplatesConfig(config.templates, rootDir);
|
|
2652
|
+
config.templates = templateConfig;
|
|
2653
|
+
|
|
2654
|
+
if (!templateConfig.enabled) {
|
|
2655
|
+
logger.info('Templates disabled by configuration.');
|
|
2656
|
+
return templateConfig;
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
expressApp.set('view engine', templateConfig.engine);
|
|
2660
|
+
expressApp.set('views', templateConfig.root);
|
|
2661
|
+
|
|
2662
|
+
logger.debug(
|
|
2663
|
+
'Templates configured: engine=%s dir=%s base=%s',
|
|
2664
|
+
templateConfig.engine,
|
|
2665
|
+
templateConfig.root,
|
|
2666
|
+
templateConfig.base || '(none)',
|
|
2667
|
+
);
|
|
2668
|
+
|
|
2669
|
+
if (!exists(templateConfig.root)) {
|
|
2670
|
+
logger.debug('Template directory does not exist yet: %s', templateConfig.root);
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
return templateConfig;
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
function attachTemplateHelpers(expressApp, templateConfig, logger, runtimeHelpers = null) {
|
|
2677
|
+
if (!templateConfig?.enabled) {
|
|
2678
|
+
return;
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
const helperSet = isPlainObject(runtimeHelpers?.helpers) ? runtimeHelpers.helpers : {};
|
|
2682
|
+
const jliveBridge = runtimeHelpers?.jlive || null;
|
|
2683
|
+
const runtimeEnv = isPlainObject(runtimeHelpers?.env) ? runtimeHelpers.env : {};
|
|
2684
|
+
const customLocalsSource = templateConfig?.locals;
|
|
2685
|
+
|
|
2686
|
+
expressApp.use((req, res, next) => {
|
|
2687
|
+
let customLocals = {};
|
|
2688
|
+
try {
|
|
2689
|
+
if (typeof customLocalsSource === 'function') {
|
|
2690
|
+
const computed = customLocalsSource({
|
|
2691
|
+
req,
|
|
2692
|
+
res,
|
|
2693
|
+
helpers: helperSet,
|
|
2694
|
+
jlive: jliveBridge,
|
|
2695
|
+
env: runtimeEnv,
|
|
2696
|
+
});
|
|
2697
|
+
customLocals = isPlainObject(computed) ? computed : {};
|
|
2698
|
+
} else if (isPlainObject(customLocalsSource)) {
|
|
2699
|
+
customLocals = customLocalsSource;
|
|
2700
|
+
}
|
|
2701
|
+
} catch (error) {
|
|
2702
|
+
logger.error('templates.locals resolver failed: %s', error?.message || String(error));
|
|
2703
|
+
customLocals = {};
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
for (const [key, value] of Object.entries(customLocals)) {
|
|
2707
|
+
if (!Object.prototype.hasOwnProperty.call(res.locals, key)) {
|
|
2708
|
+
res.locals[key] = value;
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
res.locals.helpers = res.locals.helpers || helperSet;
|
|
2713
|
+
res.locals.jlive = res.locals.jlive || jliveBridge;
|
|
2714
|
+
|
|
2715
|
+
if (!Object.prototype.hasOwnProperty.call(res.locals, 'locale') && typeof req.aegis?.locale === 'string') {
|
|
2716
|
+
res.locals.locale = req.aegis.locale;
|
|
2717
|
+
}
|
|
2718
|
+
if (!Object.prototype.hasOwnProperty.call(res.locals, 'i18n') && req.aegis?.i18n) {
|
|
2719
|
+
res.locals.i18n = req.aegis.i18n;
|
|
2720
|
+
}
|
|
2721
|
+
if (!Object.prototype.hasOwnProperty.call(res.locals, 't') && typeof req.aegis?.t === 'function') {
|
|
2722
|
+
res.locals.t = req.aegis.t;
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
if (!Object.prototype.hasOwnProperty.call(res.locals, 'money') && typeof helperSet.money === 'function') {
|
|
2726
|
+
res.locals.money = helperSet.money;
|
|
2727
|
+
}
|
|
2728
|
+
if (!Object.prototype.hasOwnProperty.call(res.locals, 'number') && typeof helperSet.number === 'function') {
|
|
2729
|
+
res.locals.number = helperSet.number;
|
|
2730
|
+
}
|
|
2731
|
+
if (!Object.prototype.hasOwnProperty.call(res.locals, 'dateTime') && typeof helperSet.dateTime === 'function') {
|
|
2732
|
+
res.locals.dateTime = helperSet.dateTime;
|
|
2733
|
+
}
|
|
2734
|
+
if (!Object.prototype.hasOwnProperty.call(res.locals, 'timeElapsed') && typeof helperSet.timeElapsed === 'function') {
|
|
2735
|
+
res.locals.timeElapsed = helperSet.timeElapsed;
|
|
2736
|
+
}
|
|
2737
|
+
if (!Object.prototype.hasOwnProperty.call(res.locals, 'timeDifference') && typeof helperSet.timeDifference === 'function') {
|
|
2738
|
+
res.locals.timeDifference = helperSet.timeDifference;
|
|
2739
|
+
}
|
|
2740
|
+
if (!Object.prototype.hasOwnProperty.call(res.locals, 'breakStr') && typeof helperSet.breakStr === 'function') {
|
|
2741
|
+
res.locals.breakStr = helperSet.breakStr;
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
res.render = (viewName, localsOrCallback, maybeCallback) => {
|
|
2745
|
+
let callback = null;
|
|
2746
|
+
let providedLocals = {};
|
|
2747
|
+
|
|
2748
|
+
if (typeof localsOrCallback === 'function') {
|
|
2749
|
+
callback = localsOrCallback;
|
|
2750
|
+
} else if (localsOrCallback === undefined || localsOrCallback === null) {
|
|
2751
|
+
providedLocals = {};
|
|
2752
|
+
} else if (typeof localsOrCallback === 'object') {
|
|
2753
|
+
providedLocals = localsOrCallback;
|
|
2754
|
+
} else {
|
|
2755
|
+
const error = new Error('res.render locals must be an object.');
|
|
2756
|
+
if (typeof maybeCallback === 'function') {
|
|
2757
|
+
maybeCallback(error);
|
|
2758
|
+
return res;
|
|
2759
|
+
}
|
|
2760
|
+
throw error;
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
if (typeof maybeCallback === 'function') {
|
|
2764
|
+
callback = maybeCallback;
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
const renderPage = async () => {
|
|
2768
|
+
const pageTemplate = normalizeTemplateName(viewName, 'view');
|
|
2769
|
+
const scopedLocals = {
|
|
2770
|
+
...res.locals,
|
|
2771
|
+
...providedLocals,
|
|
2772
|
+
};
|
|
2773
|
+
|
|
2774
|
+
const layoutOverride = Object.prototype.hasOwnProperty.call(providedLocals, 'layout')
|
|
2775
|
+
? providedLocals.layout
|
|
2776
|
+
: undefined;
|
|
2777
|
+
|
|
2778
|
+
delete scopedLocals.layout;
|
|
2779
|
+
|
|
2780
|
+
const appScopedLayout = (() => {
|
|
2781
|
+
const appName = typeof req?.aegis?.appName === 'string' ? req.aegis.appName.trim() : '';
|
|
2782
|
+
if (!appName || !isPlainObject(templateConfig.appBases)) {
|
|
2783
|
+
return undefined;
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
if (!Object.prototype.hasOwnProperty.call(templateConfig.appBases, appName)) {
|
|
2787
|
+
return undefined;
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
return templateConfig.appBases[appName];
|
|
2791
|
+
})();
|
|
2792
|
+
|
|
2793
|
+
const requestedLayout = layoutOverride === false || layoutOverride === null
|
|
2794
|
+
? null
|
|
2795
|
+
: (typeof layoutOverride === 'string' && layoutOverride.trim().length > 0
|
|
2796
|
+
? normalizeTemplateName(layoutOverride, 'layout')
|
|
2797
|
+
: (appScopedLayout === null
|
|
2798
|
+
? null
|
|
2799
|
+
: (typeof appScopedLayout === 'string' && appScopedLayout.length > 0
|
|
2800
|
+
? normalizeTemplateName(appScopedLayout, 'app layout')
|
|
2801
|
+
: templateConfig.base)));
|
|
2802
|
+
|
|
2803
|
+
const body = await renderTemplateFile({
|
|
2804
|
+
templatesRoot: templateConfig.root,
|
|
2805
|
+
templateName: pageTemplate,
|
|
2806
|
+
locals: scopedLocals,
|
|
2807
|
+
});
|
|
2808
|
+
|
|
2809
|
+
if (!requestedLayout) {
|
|
2810
|
+
return body;
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
const layoutLocals = {
|
|
2814
|
+
...scopedLocals,
|
|
2815
|
+
body,
|
|
2816
|
+
content: body,
|
|
2817
|
+
pageTemplate,
|
|
2818
|
+
};
|
|
2819
|
+
|
|
2820
|
+
return renderTemplateFile({
|
|
2821
|
+
templatesRoot: templateConfig.root,
|
|
2822
|
+
templateName: requestedLayout,
|
|
2823
|
+
locals: layoutLocals,
|
|
2824
|
+
});
|
|
2825
|
+
};
|
|
2826
|
+
|
|
2827
|
+
if (callback) {
|
|
2828
|
+
renderPage()
|
|
2829
|
+
.then((html) => callback(null, html))
|
|
2830
|
+
.catch((error) => callback(error));
|
|
2831
|
+
return res;
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
return renderPage()
|
|
2835
|
+
.then((html) => {
|
|
2836
|
+
if (!res.headersSent) {
|
|
2837
|
+
res.type('html').send(html);
|
|
2838
|
+
}
|
|
2839
|
+
return res;
|
|
2840
|
+
})
|
|
2841
|
+
.catch((error) => {
|
|
2842
|
+
logger.error('Template render failed for "%s": %s', String(viewName), error?.message || String(error));
|
|
2843
|
+
if (!res.headersSent) {
|
|
2844
|
+
res.status(500).json({ error: 'Template render error' });
|
|
2845
|
+
}
|
|
2846
|
+
return res;
|
|
2847
|
+
});
|
|
2848
|
+
};
|
|
2849
|
+
|
|
2850
|
+
next();
|
|
2851
|
+
});
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
function buildContext({
|
|
2855
|
+
rootDir,
|
|
2856
|
+
config,
|
|
2857
|
+
env = {},
|
|
2858
|
+
i18n = null,
|
|
2859
|
+
logger,
|
|
2860
|
+
container,
|
|
2861
|
+
events,
|
|
2862
|
+
expressApp,
|
|
2863
|
+
server,
|
|
2864
|
+
io,
|
|
2865
|
+
database,
|
|
2866
|
+
cache,
|
|
2867
|
+
templates,
|
|
2868
|
+
auth = null,
|
|
2869
|
+
mail = null,
|
|
2870
|
+
helpers = {},
|
|
2871
|
+
jlive = null,
|
|
2872
|
+
upload = null,
|
|
2873
|
+
protocol = 'http',
|
|
2874
|
+
}) {
|
|
2875
|
+
return {
|
|
2876
|
+
rootDir,
|
|
2877
|
+
config,
|
|
2878
|
+
env,
|
|
2879
|
+
i18n,
|
|
2880
|
+
logger,
|
|
2881
|
+
container,
|
|
2882
|
+
events,
|
|
2883
|
+
app: expressApp,
|
|
2884
|
+
server,
|
|
2885
|
+
io,
|
|
2886
|
+
database,
|
|
2887
|
+
dbClient: database?.client ?? null,
|
|
2888
|
+
cache,
|
|
2889
|
+
templates,
|
|
2890
|
+
auth,
|
|
2891
|
+
mail,
|
|
2892
|
+
helpers,
|
|
2893
|
+
jlive,
|
|
2894
|
+
upload,
|
|
2895
|
+
protocol,
|
|
2896
|
+
declaredAppNames: new Set(),
|
|
2897
|
+
};
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
function attachDefaultMiddlewares(expressApp, config, rootDir) {
|
|
2901
|
+
expressApp.use(express.json({ limit: '2mb' }));
|
|
2902
|
+
expressApp.use(express.urlencoded({ extended: true }));
|
|
2903
|
+
|
|
2904
|
+
if (typeof config.staticDir === 'string' && config.staticDir.trim().length > 0) {
|
|
2905
|
+
const staticPath = path.join(rootDir, config.staticDir);
|
|
2906
|
+
expressApp.use(express.static(staticPath));
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
function sendMaintenanceFallbackResponse(req, res, config, maintenanceConfig, maintenanceTemplate, logger) {
|
|
2911
|
+
try {
|
|
2912
|
+
const requestPath = typeof req?.aegis?.maintenance?.requestedPath === 'string'
|
|
2913
|
+
? req.aegis.maintenance.requestedPath
|
|
2914
|
+
: String(req?.path || req?.originalUrl || '/');
|
|
2915
|
+
const html = maintenanceConfig.html || renderDefaultMaintenancePage(
|
|
2916
|
+
maintenanceTemplate,
|
|
2917
|
+
config,
|
|
2918
|
+
maintenanceConfig,
|
|
2919
|
+
requestPath,
|
|
2920
|
+
);
|
|
2921
|
+
|
|
2922
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
2923
|
+
if (maintenanceConfig.retryAfter) {
|
|
2924
|
+
res.setHeader('Retry-After', maintenanceConfig.retryAfter);
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
return res.status(maintenanceConfig.statusCode).type('html').send(html);
|
|
2928
|
+
} catch (error) {
|
|
2929
|
+
logger.error('Maintenance page render failed: %s', error?.message || String(error));
|
|
2930
|
+
return res.status(500).json({ error: 'Template render error' });
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
function attachMaintenanceMode(expressApp, config, logger, maintenanceTemplate) {
|
|
2935
|
+
const maintenanceConfig = isPlainObject(config?.maintenance)
|
|
2936
|
+
? config.maintenance
|
|
2937
|
+
: normalizeMaintenanceConfig(config?.maintenance, config?.appName || 'AegisNode');
|
|
2938
|
+
config.maintenance = maintenanceConfig;
|
|
2939
|
+
|
|
2940
|
+
if (!maintenanceConfig.enabled) {
|
|
2941
|
+
return null;
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
expressApp.use((req, res, next) => {
|
|
2945
|
+
const requestPath = String(req.path || req.originalUrl || '/');
|
|
2946
|
+
const isExcluded = maintenanceConfig.excludePaths.some((prefix) => requestPathMatchesPrefix(requestPath, prefix));
|
|
2947
|
+
|
|
2948
|
+
if (isExcluded) {
|
|
2949
|
+
return next();
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
req.aegis = req.aegis || {};
|
|
2953
|
+
req.aegis.maintenance = {
|
|
2954
|
+
enabled: true,
|
|
2955
|
+
requestedPath: requestPath,
|
|
2956
|
+
route: maintenanceConfig.route,
|
|
2957
|
+
rewritten: false,
|
|
2958
|
+
originalUrl: req.originalUrl,
|
|
2959
|
+
};
|
|
2960
|
+
|
|
2961
|
+
if (!maintenanceConfig.route) {
|
|
2962
|
+
return sendMaintenanceFallbackResponse(req, res, config, maintenanceConfig, maintenanceTemplate, logger);
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
2966
|
+
if (maintenanceConfig.retryAfter) {
|
|
2967
|
+
res.setHeader('Retry-After', maintenanceConfig.retryAfter);
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
if (requestPath !== maintenanceConfig.route) {
|
|
2971
|
+
const rawUrl = String(req.url || requestPath);
|
|
2972
|
+
const queryIndex = rawUrl.indexOf('?');
|
|
2973
|
+
const query = queryIndex >= 0 ? rawUrl.slice(queryIndex) : '';
|
|
2974
|
+
|
|
2975
|
+
req.aegis.maintenance.rewritten = true;
|
|
2976
|
+
req.url = `${maintenanceConfig.route}${query}`;
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
res.status(maintenanceConfig.statusCode);
|
|
2980
|
+
return next();
|
|
2981
|
+
});
|
|
2982
|
+
|
|
2983
|
+
logger.info(
|
|
2984
|
+
'Maintenance mode enabled. Returning HTML with status %s%s%s.',
|
|
2985
|
+
maintenanceConfig.statusCode,
|
|
2986
|
+
maintenanceConfig.route
|
|
2987
|
+
? ` via route ${maintenanceConfig.route}`
|
|
2988
|
+
: ' via internal fallback',
|
|
2989
|
+
maintenanceConfig.excludePaths.length > 0
|
|
2990
|
+
? ` (excluded paths: ${maintenanceConfig.excludePaths.join(', ')})`
|
|
2991
|
+
: '',
|
|
2992
|
+
);
|
|
2993
|
+
|
|
2994
|
+
return (req, res, next) => {
|
|
2995
|
+
if (res.headersSent || req.aegis?.maintenance?.enabled !== true) {
|
|
2996
|
+
return next();
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
return sendMaintenanceFallbackResponse(req, res, config, maintenanceConfig, maintenanceTemplate, logger);
|
|
3000
|
+
};
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
function attachSecurityMiddlewares(expressApp, config, logger) {
|
|
3004
|
+
if (!isPlainObject(config.security)) {
|
|
3005
|
+
config.security = {};
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
const headersConfig = normalizeSecurityHeadersConfig(config.security);
|
|
3009
|
+
config.security.headers = headersConfig;
|
|
3010
|
+
|
|
3011
|
+
if (!headersConfig.enabled) {
|
|
3012
|
+
logger.info('Security headers disabled by configuration.');
|
|
3013
|
+
return;
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
const helmetOptions = {};
|
|
3017
|
+
|
|
3018
|
+
if (headersConfig.csp.enabled) {
|
|
3019
|
+
helmetOptions.contentSecurityPolicy = {
|
|
3020
|
+
useDefaults: true,
|
|
3021
|
+
directives: buildCspDirectives(headersConfig.csp.directives),
|
|
3022
|
+
reportOnly: headersConfig.csp.reportOnly,
|
|
3023
|
+
};
|
|
3024
|
+
} else {
|
|
3025
|
+
helmetOptions.contentSecurityPolicy = false;
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
expressApp.use(helmet(helmetOptions));
|
|
3029
|
+
logger.debug(
|
|
3030
|
+
'Security headers middleware enabled. CSP=%s',
|
|
3031
|
+
headersConfig.csp.enabled ? 'on' : 'off',
|
|
3032
|
+
);
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
function attachDdosProtection(expressApp, config, logger) {
|
|
3036
|
+
if (!isPlainObject(config.security)) {
|
|
3037
|
+
config.security = {};
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
const ddosConfig = normalizeDdosConfig(config.security);
|
|
3041
|
+
config.security.ddos = ddosConfig;
|
|
3042
|
+
|
|
3043
|
+
if (!ddosConfig.enabled) {
|
|
3044
|
+
logger.info('DDoS rate limiter disabled by configuration.');
|
|
3045
|
+
return;
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
const limiter = rateLimit({
|
|
3049
|
+
windowMs: ddosConfig.windowMs,
|
|
3050
|
+
limit: ddosConfig.maxRequests,
|
|
3051
|
+
standardHeaders: ddosConfig.standardHeaders,
|
|
3052
|
+
legacyHeaders: ddosConfig.legacyHeaders,
|
|
3053
|
+
skipSuccessfulRequests: ddosConfig.skipSuccessfulRequests,
|
|
3054
|
+
skipFailedRequests: ddosConfig.skipFailedRequests,
|
|
3055
|
+
statusCode: ddosConfig.statusCode,
|
|
3056
|
+
message: { error: ddosConfig.message },
|
|
3057
|
+
store: ddosConfig.store || undefined,
|
|
3058
|
+
skip: (req) => {
|
|
3059
|
+
const requestPath = String(req.path || req.originalUrl || '/');
|
|
3060
|
+
return ddosConfig.skipPaths.some((prefix) => {
|
|
3061
|
+
if (prefix === '/') {
|
|
3062
|
+
return requestPath === '/';
|
|
3063
|
+
}
|
|
3064
|
+
return requestPath === prefix || requestPath.startsWith(`${prefix}/`);
|
|
3065
|
+
});
|
|
3066
|
+
},
|
|
3067
|
+
});
|
|
3068
|
+
|
|
3069
|
+
expressApp.use(limiter);
|
|
3070
|
+
logger.debug(
|
|
3071
|
+
'DDoS rate limiter enabled: max=%s windowMs=%s',
|
|
3072
|
+
ddosConfig.maxRequests,
|
|
3073
|
+
ddosConfig.windowMs,
|
|
3074
|
+
);
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
function attachCsrfProtection(expressApp, config, logger, auth = null) {
|
|
3078
|
+
if (!isPlainObject(config.security)) {
|
|
3079
|
+
config.security = {};
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
const csrfConfig = normalizeCsrfConfig(config.security);
|
|
3083
|
+
config.security.csrf = csrfConfig;
|
|
3084
|
+
const appSecret = resolveAppSecret(config.security);
|
|
3085
|
+
|
|
3086
|
+
if (!csrfConfig.enabled) {
|
|
3087
|
+
logger.info('CSRF protection disabled by configuration.');
|
|
3088
|
+
return;
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
if (!appSecret && csrfConfig.requireSignedCookie) {
|
|
3092
|
+
throw new Error('CSRF protection requires a strong security.appSecret (min length 16) to sign CSRF cookies. Set security.appSecret or set security.csrf.requireSignedCookie=false (not recommended).');
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
if (!appSecret) {
|
|
3096
|
+
logger.warn('security.appSecret is missing or too short: CSRF cookie signing is disabled. Set a strong appSecret in settings.js.');
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
expressApp.use((req, res, next) => {
|
|
3100
|
+
if (config.api?.disableCsrf === true && req.aegis?.isApiRequest === true) {
|
|
3101
|
+
return next();
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
if (
|
|
3105
|
+
auth
|
|
3106
|
+
&& auth.provider === 'oauth2'
|
|
3107
|
+
&& auth.oauth2Server?.enabled === true
|
|
3108
|
+
&& typeof auth.isOAuthServerRequestPath === 'function'
|
|
3109
|
+
&& auth.isOAuthServerRequestPath(String(req.path || req.originalUrl || '/'))
|
|
3110
|
+
) {
|
|
3111
|
+
return next();
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
const cookies = parseCookies(req.headers?.cookie);
|
|
3115
|
+
const parsed = decodeCsrfCookieValue(cookies[csrfConfig.cookieName], appSecret);
|
|
3116
|
+
let token = parsed.valid ? parsed.token : '';
|
|
3117
|
+
let shouldSetCookie = false;
|
|
3118
|
+
|
|
3119
|
+
if (typeof token !== 'string' || token.length < 32) {
|
|
3120
|
+
token = crypto.randomBytes(32).toString('hex');
|
|
3121
|
+
shouldSetCookie = true;
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
if (shouldSetCookie) {
|
|
3125
|
+
res.cookie(csrfConfig.cookieName, encodeCsrfCookieValue(token, appSecret), {
|
|
3126
|
+
encode: (value) => value,
|
|
3127
|
+
httpOnly: csrfConfig.httpOnly,
|
|
3128
|
+
sameSite: csrfConfig.sameSite,
|
|
3129
|
+
secure: resolveSecureCookieFlag(req, csrfConfig.secure),
|
|
3130
|
+
path: csrfConfig.path,
|
|
3131
|
+
});
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
req.csrfToken = () => token;
|
|
3135
|
+
res.locals.csrfValue = token;
|
|
3136
|
+
res.locals.csrfToken = markRawHtml(
|
|
3137
|
+
`<input type="hidden" name="${ejs.escapeXML(csrfConfig.fieldName)}" value="${ejs.escapeXML(token)}" />`,
|
|
3138
|
+
);
|
|
3139
|
+
|
|
3140
|
+
const unsafeMethod = !isSafeHttpMethod(req.method);
|
|
3141
|
+
const shouldValidate = unsafeMethod && (
|
|
3142
|
+
csrfConfig.rejectUnsafeMethods
|
|
3143
|
+
|| (csrfConfig.rejectForms && isFormSubmissionRequest(req))
|
|
3144
|
+
);
|
|
3145
|
+
|
|
3146
|
+
if (!shouldValidate) {
|
|
3147
|
+
return next();
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
const provided = extractCsrfToken(req, csrfConfig);
|
|
3151
|
+
if (!provided || !constantTimeEqual(provided, token)) {
|
|
3152
|
+
return res.status(403).json({ error: 'CSRF token missing or invalid' });
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
return next();
|
|
3156
|
+
});
|
|
3157
|
+
|
|
3158
|
+
logger.debug('CSRF protection middleware enabled for unsafe requests.');
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
function attachApiMiddlewares(expressApp, config, declaredApps, logger) {
|
|
3162
|
+
const apiConfig = normalizeApiConfig(config.api, declaredApps || config.apps || []);
|
|
3163
|
+
config.api = apiConfig;
|
|
3164
|
+
const uploadsConfig = config.uploads && typeof config.uploads === 'object' ? config.uploads : null;
|
|
3165
|
+
const allowApiMultipart = uploadsConfig?.enabled !== false && uploadsConfig?.allowApiMultipart === true;
|
|
3166
|
+
|
|
3167
|
+
if (!Array.isArray(apiConfig.mounts) || apiConfig.mounts.length === 0) {
|
|
3168
|
+
logger.debug('API app middleware disabled: no API apps configured.');
|
|
3169
|
+
return;
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
expressApp.use((req, res, next) => {
|
|
3173
|
+
const requestPath = String(req.path || req.originalUrl || '/');
|
|
3174
|
+
const isApiRequest = apiConfig.mounts.some((mount) => requestPathMatchesPrefix(requestPath, mount));
|
|
3175
|
+
|
|
3176
|
+
req.aegis = req.aegis || {};
|
|
3177
|
+
req.aegis.isApiRequest = isApiRequest;
|
|
3178
|
+
|
|
3179
|
+
if (!isApiRequest) {
|
|
3180
|
+
return next();
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
if (apiConfig.noStoreHeaders) {
|
|
3184
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
if (
|
|
3188
|
+
apiConfig.requireJsonForUnsafeMethods
|
|
3189
|
+
&& !isSafeHttpMethod(req.method)
|
|
3190
|
+
&& hasRequestBody(req)
|
|
3191
|
+
&& !(allowApiMultipart && isMultipartRequestContentType(req.headers?.['content-type']))
|
|
3192
|
+
&& !isJsonRequestContentType(req.headers?.['content-type'])
|
|
3193
|
+
) {
|
|
3194
|
+
return res.status(415).json({
|
|
3195
|
+
error: 'API endpoints accept application/json payloads only',
|
|
3196
|
+
});
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
return next();
|
|
3200
|
+
});
|
|
3201
|
+
|
|
3202
|
+
logger.debug('API middleware enabled for mounts: %s', apiConfig.mounts.join(', '));
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
function attachOAuth2AuthorizationServer(expressApp, auth, logger) {
|
|
3206
|
+
if (!auth || auth.provider !== 'oauth2' || auth.oauth2Server?.enabled !== true) {
|
|
3207
|
+
logger.debug('OAuth2 authorization server endpoints disabled.');
|
|
3208
|
+
return;
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
const handlers = auth.oauth2Server.handlers || {};
|
|
3212
|
+
const paths = auth.oauth2Server.paths || {};
|
|
3213
|
+
if (
|
|
3214
|
+
typeof handlers.metadata !== 'function'
|
|
3215
|
+
|| typeof handlers.authorize !== 'function'
|
|
3216
|
+
|| typeof handlers.token !== 'function'
|
|
3217
|
+
|| typeof handlers.introspect !== 'function'
|
|
3218
|
+
|| typeof handlers.revoke !== 'function'
|
|
3219
|
+
|| typeof paths.metadata !== 'string'
|
|
3220
|
+
|| typeof paths.authorize !== 'string'
|
|
3221
|
+
|| typeof paths.token !== 'string'
|
|
3222
|
+
|| typeof paths.introspect !== 'string'
|
|
3223
|
+
|| typeof paths.revoke !== 'string'
|
|
3224
|
+
) {
|
|
3225
|
+
logger.warn('OAuth2 authorization server handlers are incomplete. Endpoints were not mounted.');
|
|
3226
|
+
return;
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
expressApp.get(paths.metadata, handlers.metadata);
|
|
3230
|
+
expressApp.get(paths.authorize, handlers.authorize);
|
|
3231
|
+
expressApp.post(paths.authorize, handlers.authorize);
|
|
3232
|
+
expressApp.post(paths.token, handlers.token);
|
|
3233
|
+
expressApp.post(paths.introspect, handlers.introspect);
|
|
3234
|
+
expressApp.post(paths.revoke, handlers.revoke);
|
|
3235
|
+
|
|
3236
|
+
logger.info(
|
|
3237
|
+
'OAuth2 authorization server mounted (authorize=%s token=%s introspect=%s revoke=%s metadata=%s)',
|
|
3238
|
+
paths.authorize,
|
|
3239
|
+
paths.token,
|
|
3240
|
+
paths.introspect,
|
|
3241
|
+
paths.revoke,
|
|
3242
|
+
paths.metadata,
|
|
3243
|
+
);
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
function buildDefaultSwaggerDocument(config) {
|
|
3247
|
+
const protocol = resolveServerProtocol(config);
|
|
3248
|
+
return {
|
|
3249
|
+
openapi: '3.0.3',
|
|
3250
|
+
info: {
|
|
3251
|
+
title: `${config.appName || 'AegisNode'} API`,
|
|
3252
|
+
version: '1.0.0',
|
|
3253
|
+
description: 'Default OpenAPI document generated by AegisNode.',
|
|
3254
|
+
},
|
|
3255
|
+
servers: [
|
|
3256
|
+
{
|
|
3257
|
+
url: `${protocol}://${config.host || '0.0.0.0'}:${config.port || 3000}`,
|
|
3258
|
+
},
|
|
3259
|
+
],
|
|
3260
|
+
paths: {},
|
|
3261
|
+
};
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
async function loadSwaggerDocumentFromFile(swaggerConfig, rootDir, logger) {
|
|
3265
|
+
const sourcePath = path.isAbsolute(swaggerConfig.documentPath)
|
|
3266
|
+
? swaggerConfig.documentPath
|
|
3267
|
+
: path.join(rootDir, swaggerConfig.documentPath);
|
|
3268
|
+
|
|
3269
|
+
if (!exists(sourcePath)) {
|
|
3270
|
+
return null;
|
|
3271
|
+
}
|
|
3272
|
+
|
|
3273
|
+
try {
|
|
3274
|
+
const raw = await fsPromises.readFile(sourcePath, 'utf8');
|
|
3275
|
+
const parsed = JSON.parse(raw);
|
|
3276
|
+
if (!isPlainObject(parsed)) {
|
|
3277
|
+
throw new Error('OpenAPI document must be a JSON object.');
|
|
3278
|
+
}
|
|
3279
|
+
return parsed;
|
|
3280
|
+
} catch (error) {
|
|
3281
|
+
logger.warn('Swagger document parsing failed at %s: %s', sourcePath, error?.message || String(error));
|
|
3282
|
+
return null;
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
|
|
3286
|
+
async function attachSwaggerMiddlewares(expressApp, config, rootDir, logger) {
|
|
3287
|
+
const swaggerConfig = normalizeSwaggerConfig(config.swagger);
|
|
3288
|
+
config.swagger = swaggerConfig;
|
|
3289
|
+
|
|
3290
|
+
if (!swaggerConfig.enabled) {
|
|
3291
|
+
logger.debug('Swagger UI disabled by configuration.');
|
|
3292
|
+
return;
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
const loadedDocument = swaggerConfig.document
|
|
3296
|
+
|| await loadSwaggerDocumentFromFile(swaggerConfig, rootDir, logger)
|
|
3297
|
+
|| buildDefaultSwaggerDocument(config);
|
|
3298
|
+
config.swagger.document = loadedDocument;
|
|
3299
|
+
|
|
3300
|
+
expressApp.get(swaggerConfig.jsonPath, (req, res) => {
|
|
3301
|
+
res.json(loadedDocument);
|
|
3302
|
+
});
|
|
3303
|
+
|
|
3304
|
+
expressApp.use(
|
|
3305
|
+
swaggerConfig.docsPath,
|
|
3306
|
+
swaggerUi.serve,
|
|
3307
|
+
swaggerUi.setup(loadedDocument, {
|
|
3308
|
+
explorer: swaggerConfig.explorer,
|
|
3309
|
+
}),
|
|
3310
|
+
);
|
|
3311
|
+
|
|
3312
|
+
logger.info('Swagger UI mounted at %s (OpenAPI JSON: %s)', swaggerConfig.docsPath, swaggerConfig.jsonPath);
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3315
|
+
function attachErrorHandlers(expressApp, logger) {
|
|
3316
|
+
expressApp.use((req, res) => {
|
|
3317
|
+
res.status(404).json({ error: 'Not Found' });
|
|
3318
|
+
});
|
|
3319
|
+
|
|
3320
|
+
expressApp.use((error, req, res, next) => {
|
|
3321
|
+
logger.error(error?.stack || error?.message || String(error));
|
|
3322
|
+
|
|
3323
|
+
if (res.headersSent) {
|
|
3324
|
+
return next(error);
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
return res.status(error?.statusCode || 500).json({
|
|
3328
|
+
error: 'Internal Server Error',
|
|
3329
|
+
});
|
|
3330
|
+
});
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
function attachSocketLifecycle(io, events) {
|
|
3334
|
+
if (!io) {
|
|
3335
|
+
return;
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
io.on('connection', (socket) => {
|
|
3339
|
+
events.publish('ws.connection', { socket });
|
|
3340
|
+
|
|
3341
|
+
socket.on('disconnect', (reason) => {
|
|
3342
|
+
events.publish('ws.disconnect', { socket, reason });
|
|
3343
|
+
});
|
|
3344
|
+
});
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
export async function createKernel({ rootDir = process.cwd(), overrides = {} } = {}) {
|
|
3348
|
+
const loadedConfig = await loadProjectConfig(rootDir);
|
|
3349
|
+
const config = deepMerge(loadedConfig, overrides || {});
|
|
3350
|
+
config.rootDir = rootDir;
|
|
3351
|
+
const logger = createLogger({ level: config.logging?.level, name: config.appName || 'aegisnode' });
|
|
3352
|
+
const resolvedAppSecret = ensureAppSecret(config, rootDir, logger);
|
|
3353
|
+
config.apps = normalizeApps(config.apps || []);
|
|
3354
|
+
config.api = normalizeApiConfig(config.api, config.apps);
|
|
3355
|
+
config.auth = normalizeAuthConfig(config.auth, {
|
|
3356
|
+
appName: config.appName || path.basename(rootDir),
|
|
3357
|
+
appSecret: resolvedAppSecret,
|
|
3358
|
+
});
|
|
3359
|
+
config.swagger = normalizeSwaggerConfig(config.swagger);
|
|
3360
|
+
config.maintenance = normalizeMaintenanceConfig(config.maintenance, config.appName || path.basename(rootDir));
|
|
3361
|
+
config.architecture = normalizeArchitectureConfig(config.architecture);
|
|
3362
|
+
config.uploads = normalizeUploadsConfig(config.uploads, rootDir);
|
|
3363
|
+
config.mail = normalizeMailConfig(config.mail);
|
|
3364
|
+
|
|
3365
|
+
const runtimeEnv = Object.freeze({
|
|
3366
|
+
...process.env,
|
|
3367
|
+
APP_SECRET: process.env.APP_SECRET || config.security?.appSecret || '',
|
|
3368
|
+
});
|
|
3369
|
+
const defaultInstallTemplate = await loadDefaultInstallTemplate(logger);
|
|
3370
|
+
const defaultMaintenanceTemplate = await loadDefaultMaintenanceTemplate(logger);
|
|
3371
|
+
const runtimeHelpers = await createRuntimeHelpers({ logger, config });
|
|
3372
|
+
const i18nConfig = normalizeI18nConfig(config.i18n, rootDir, logger);
|
|
3373
|
+
config.i18n = i18nConfig;
|
|
3374
|
+
const runtimeI18n = createRuntimeI18n(i18nConfig);
|
|
3375
|
+
const upload = await createUploadManager(config.uploads, logger);
|
|
3376
|
+
const container = createContainer();
|
|
3377
|
+
const events = createEventBus();
|
|
3378
|
+
const expressApp = express();
|
|
3379
|
+
const templateConfig = configureTemplateEngine(expressApp, config, rootDir, logger);
|
|
3380
|
+
applyTrustProxySetting(expressApp, config, logger);
|
|
3381
|
+
const websocketCorsConfig = Object.prototype.hasOwnProperty.call(config.websocket || {}, 'cors')
|
|
3382
|
+
? config.websocket.cors
|
|
3383
|
+
: { origin: false };
|
|
3384
|
+
const { server, protocol: serverProtocol } = createHttpServer(expressApp, config);
|
|
3385
|
+
const io = config.websocket?.enabled === false
|
|
3386
|
+
? null
|
|
3387
|
+
: new SocketIOServer(server, {
|
|
3388
|
+
cors: websocketCorsConfig ?? { origin: false },
|
|
3389
|
+
});
|
|
3390
|
+
|
|
3391
|
+
const database = await initializeDatabase(config.database, logger);
|
|
3392
|
+
const cache = createCache(config.cache, logger);
|
|
3393
|
+
const auth = createAuthManager({
|
|
3394
|
+
config: config.auth,
|
|
3395
|
+
cache,
|
|
3396
|
+
logger,
|
|
3397
|
+
rootDir,
|
|
3398
|
+
database,
|
|
3399
|
+
});
|
|
3400
|
+
if (auth?.ready && typeof auth.ready.then === 'function') {
|
|
3401
|
+
await auth.ready;
|
|
3402
|
+
}
|
|
3403
|
+
const mail = await createMailManager(config.mail, logger);
|
|
3404
|
+
|
|
3405
|
+
container.set('config', config);
|
|
3406
|
+
container.set('env', runtimeEnv);
|
|
3407
|
+
container.set('logger', logger);
|
|
3408
|
+
container.set('events', events);
|
|
3409
|
+
container.set('app', expressApp);
|
|
3410
|
+
container.set('server', server);
|
|
3411
|
+
container.set('protocol', serverProtocol);
|
|
3412
|
+
container.set('io', io);
|
|
3413
|
+
container.set('database', database);
|
|
3414
|
+
container.set('dbClient', database?.client ?? null);
|
|
3415
|
+
container.set('cache', cache);
|
|
3416
|
+
container.set('auth', auth);
|
|
3417
|
+
container.set('mail', mail);
|
|
3418
|
+
container.set('templates', templateConfig);
|
|
3419
|
+
container.set('helpers', runtimeHelpers.helpers);
|
|
3420
|
+
container.set('jlive', runtimeHelpers.jlive);
|
|
3421
|
+
container.set('i18n', runtimeI18n);
|
|
3422
|
+
container.set('upload', upload);
|
|
3423
|
+
|
|
3424
|
+
const context = buildContext({
|
|
3425
|
+
rootDir,
|
|
3426
|
+
config,
|
|
3427
|
+
env: runtimeEnv,
|
|
3428
|
+
i18n: runtimeI18n,
|
|
3429
|
+
logger,
|
|
3430
|
+
container,
|
|
3431
|
+
events,
|
|
3432
|
+
expressApp,
|
|
3433
|
+
server,
|
|
3434
|
+
io,
|
|
3435
|
+
database,
|
|
3436
|
+
cache,
|
|
3437
|
+
templates: templateConfig,
|
|
3438
|
+
auth,
|
|
3439
|
+
mail,
|
|
3440
|
+
helpers: runtimeHelpers.helpers,
|
|
3441
|
+
jlive: runtimeHelpers.jlive,
|
|
3442
|
+
upload,
|
|
3443
|
+
protocol: serverProtocol,
|
|
3444
|
+
});
|
|
3445
|
+
const layerAccessors = createLayerAccessors({ container, context });
|
|
3446
|
+
context.services = layerAccessors.services;
|
|
3447
|
+
context.models = layerAccessors.models;
|
|
3448
|
+
context.validators = layerAccessors.validators;
|
|
3449
|
+
container.set('services', layerAccessors.services);
|
|
3450
|
+
container.set('models', layerAccessors.models);
|
|
3451
|
+
container.set('validators', layerAccessors.validators);
|
|
3452
|
+
const strictLayers = config.architecture.strictLayers === true;
|
|
3453
|
+
|
|
3454
|
+
await runLoaders(config.loaders, context, rootDir, logger);
|
|
3455
|
+
|
|
3456
|
+
const projectRoutes = await loadProjectRoutes(rootDir);
|
|
3457
|
+
if (strictLayers) {
|
|
3458
|
+
await enforceStrictProjectRoutes(projectRoutes?.sourceFile || path.join(rootDir, 'routes.js'));
|
|
3459
|
+
}
|
|
3460
|
+
const settingsDeclaredApps = normalizeApps(config.apps || []);
|
|
3461
|
+
const settingsDeclaredAppNames = new Set(settingsDeclaredApps.map((entry) => entry.name));
|
|
3462
|
+
|
|
3463
|
+
if (projectRoutes?.apps?.length) {
|
|
3464
|
+
const projectDeclaredApps = normalizeApps(projectRoutes.apps);
|
|
3465
|
+
const unknownApps = projectDeclaredApps
|
|
3466
|
+
.map((entry) => entry.name)
|
|
3467
|
+
.filter((name) => !settingsDeclaredAppNames.has(name));
|
|
3468
|
+
|
|
3469
|
+
if (unknownApps.length) {
|
|
3470
|
+
throw new Error(
|
|
3471
|
+
`Apps used in routes.js must be declared in settings.apps: ${unknownApps.join(', ')}`,
|
|
3472
|
+
);
|
|
3473
|
+
}
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
const declaredApps = settingsDeclaredApps;
|
|
3477
|
+
context.declaredAppNames = new Set(declaredApps.map((entry) => entry.name));
|
|
3478
|
+
config.api = normalizeApiConfig(config.api, declaredApps);
|
|
3479
|
+
|
|
3480
|
+
attachSecurityMiddlewares(expressApp, config, logger);
|
|
3481
|
+
attachDdosProtection(expressApp, config, logger);
|
|
3482
|
+
attachDefaultMiddlewares(expressApp, config, rootDir);
|
|
3483
|
+
const maintenanceFallback = attachMaintenanceMode(expressApp, config, logger, defaultMaintenanceTemplate);
|
|
3484
|
+
attachApiMiddlewares(expressApp, config, declaredApps, logger);
|
|
3485
|
+
attachRequestRuntimeBridge(expressApp, context);
|
|
3486
|
+
attachOAuth2AuthorizationServer(expressApp, auth, logger);
|
|
3487
|
+
attachCsrfProtection(expressApp, config, logger, auth);
|
|
3488
|
+
await attachSwaggerMiddlewares(expressApp, config, rootDir, logger);
|
|
3489
|
+
attachTemplateHelpers(expressApp, templateConfig, logger, {
|
|
3490
|
+
...runtimeHelpers,
|
|
3491
|
+
env: runtimeEnv,
|
|
3492
|
+
});
|
|
3493
|
+
attachSocketLifecycle(io, events);
|
|
3494
|
+
|
|
3495
|
+
for (const appDefinition of declaredApps) {
|
|
3496
|
+
const appRoot = path.join(rootDir, 'apps', appDefinition.name);
|
|
3497
|
+
if (!exists(appRoot)) {
|
|
3498
|
+
throw new Error(`App not found: ${appRoot}`);
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3501
|
+
if (strictLayers) {
|
|
3502
|
+
await enforceStrictLayerArchitecture({
|
|
3503
|
+
appName: appDefinition.name,
|
|
3504
|
+
appRoot,
|
|
3505
|
+
});
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
await registerControllers({
|
|
3509
|
+
appName: appDefinition.name,
|
|
3510
|
+
appRoot,
|
|
3511
|
+
container,
|
|
3512
|
+
logger,
|
|
3513
|
+
});
|
|
3514
|
+
|
|
3515
|
+
await registerModels({
|
|
3516
|
+
appName: appDefinition.name,
|
|
3517
|
+
appRoot,
|
|
3518
|
+
container,
|
|
3519
|
+
logger,
|
|
3520
|
+
});
|
|
3521
|
+
|
|
3522
|
+
await registerValidators({
|
|
3523
|
+
appName: appDefinition.name,
|
|
3524
|
+
appRoot,
|
|
3525
|
+
container,
|
|
3526
|
+
logger,
|
|
3527
|
+
});
|
|
3528
|
+
|
|
3529
|
+
await registerServices({
|
|
3530
|
+
appName: appDefinition.name,
|
|
3531
|
+
appRoot,
|
|
3532
|
+
container,
|
|
3533
|
+
logger,
|
|
3534
|
+
});
|
|
3535
|
+
|
|
3536
|
+
const appRouteContext = buildRouteRuntimeContext({
|
|
3537
|
+
context,
|
|
3538
|
+
layerAccessors,
|
|
3539
|
+
strictLayers,
|
|
3540
|
+
appDefinition,
|
|
3541
|
+
});
|
|
3542
|
+
|
|
3543
|
+
await registerSubscribers({
|
|
3544
|
+
appName: appDefinition.name,
|
|
3545
|
+
appRoot,
|
|
3546
|
+
context: {
|
|
3547
|
+
...context,
|
|
3548
|
+
services: layerAccessors.servicesForApp(appDefinition.name),
|
|
3549
|
+
models: layerAccessors.modelsForApp(appDefinition.name),
|
|
3550
|
+
validators: layerAccessors.validatorsForApp(appDefinition.name),
|
|
3551
|
+
},
|
|
3552
|
+
logger,
|
|
3553
|
+
});
|
|
3554
|
+
|
|
3555
|
+
const appAutoMounted = config.autoMountApps === true;
|
|
3556
|
+
if (appAutoMounted) {
|
|
3557
|
+
await mountAppRoutes({
|
|
3558
|
+
appDefinition,
|
|
3559
|
+
appRoot,
|
|
3560
|
+
context,
|
|
3561
|
+
expressApp,
|
|
3562
|
+
routeContext: appRouteContext,
|
|
3563
|
+
});
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3566
|
+
events.publish('app.booted', {
|
|
3567
|
+
appName: appDefinition.name,
|
|
3568
|
+
mount: appDefinition.mount,
|
|
3569
|
+
});
|
|
3570
|
+
|
|
3571
|
+
if (appAutoMounted) {
|
|
3572
|
+
logger.info('App mounted: %s at %s', appDefinition.name, appDefinition.mount);
|
|
3573
|
+
} else {
|
|
3574
|
+
logger.info('App loaded: %s (central routes.js controls HTTP routes)', appDefinition.name);
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
const rootRouter = express.Router();
|
|
3579
|
+
const projectRouteState = { hasAny: false, hasRootGet: false };
|
|
3580
|
+
const projectRouteContext = buildRouteRuntimeContext({
|
|
3581
|
+
context,
|
|
3582
|
+
layerAccessors,
|
|
3583
|
+
strictLayers,
|
|
3584
|
+
appDefinition: null,
|
|
3585
|
+
});
|
|
3586
|
+
const routeApi = createRouteApi(
|
|
3587
|
+
rootRouter,
|
|
3588
|
+
(reference, currentApp, runtimeContext) => resolveControllerReference(reference, {
|
|
3589
|
+
container,
|
|
3590
|
+
currentApp,
|
|
3591
|
+
runtimeContext,
|
|
3592
|
+
}),
|
|
3593
|
+
null,
|
|
3594
|
+
{ routeState: projectRouteState, runtimeContext: projectRouteContext },
|
|
3595
|
+
);
|
|
3596
|
+
|
|
3597
|
+
if (projectRoutes?.routes) {
|
|
3598
|
+
await projectRoutes.routes(routeApi, EMPTY_ROUTE_CONTEXT);
|
|
3599
|
+
logger.info('Project routes mounted.');
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
if (!projectRouteState.hasRootGet) {
|
|
3603
|
+
rootRouter.get('/', (req, res) => {
|
|
3604
|
+
try {
|
|
3605
|
+
const html = renderDefaultInstallPage(defaultInstallTemplate, config);
|
|
3606
|
+
res.type('html').send(html);
|
|
3607
|
+
} catch (error) {
|
|
3608
|
+
logger.error('Default confirmation page render failed: %s', error?.message || String(error));
|
|
3609
|
+
res.status(500).json({ error: 'Template render error' });
|
|
3610
|
+
}
|
|
3611
|
+
});
|
|
3612
|
+
logger.info('Default confirmation page mounted at /.');
|
|
3613
|
+
} else {
|
|
3614
|
+
logger.info('Custom root route detected in routes.js; default confirmation page skipped.');
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
if (projectRoutes?.routes || !projectRouteState.hasRootGet) {
|
|
3618
|
+
expressApp.use('/', rootRouter);
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3621
|
+
if (typeof maintenanceFallback === 'function') {
|
|
3622
|
+
expressApp.use(maintenanceFallback);
|
|
3623
|
+
}
|
|
3624
|
+
|
|
3625
|
+
attachErrorHandlers(expressApp, logger);
|
|
3626
|
+
|
|
3627
|
+
let started = false;
|
|
3628
|
+
|
|
3629
|
+
return {
|
|
3630
|
+
config,
|
|
3631
|
+
context,
|
|
3632
|
+
start: () => new Promise((resolve, reject) => {
|
|
3633
|
+
if (started) {
|
|
3634
|
+
resolve();
|
|
3635
|
+
return;
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3638
|
+
server.listen(config.port, config.host, () => {
|
|
3639
|
+
started = true;
|
|
3640
|
+
logger.info('AegisNode server running at %s://%s:%s', serverProtocol, config.host, config.port);
|
|
3641
|
+
resolve();
|
|
3642
|
+
});
|
|
3643
|
+
|
|
3644
|
+
server.once('error', (error) => {
|
|
3645
|
+
reject(error);
|
|
3646
|
+
});
|
|
3647
|
+
}),
|
|
3648
|
+
stop: async () => {
|
|
3649
|
+
if (io) {
|
|
3650
|
+
await new Promise((resolve) => io.close(() => resolve()));
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
if (!server.listening) {
|
|
3654
|
+
if (auth && typeof auth.close === 'function') {
|
|
3655
|
+
await auth.close();
|
|
3656
|
+
}
|
|
3657
|
+
if (mail && typeof mail.close === 'function') {
|
|
3658
|
+
await mail.close();
|
|
3659
|
+
}
|
|
3660
|
+
await closeDatabase(database);
|
|
3661
|
+
events.removeAll();
|
|
3662
|
+
logger.info('AegisNode server stopped.');
|
|
3663
|
+
return;
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
await new Promise((resolve, reject) => {
|
|
3667
|
+
server.close((error) => {
|
|
3668
|
+
if (error) {
|
|
3669
|
+
if (error.code === 'ERR_SERVER_NOT_RUNNING') {
|
|
3670
|
+
resolve();
|
|
3671
|
+
return;
|
|
3672
|
+
}
|
|
3673
|
+
reject(error);
|
|
3674
|
+
return;
|
|
3675
|
+
}
|
|
3676
|
+
resolve();
|
|
3677
|
+
});
|
|
3678
|
+
});
|
|
3679
|
+
|
|
3680
|
+
if (auth && typeof auth.close === 'function') {
|
|
3681
|
+
await auth.close();
|
|
3682
|
+
}
|
|
3683
|
+
if (mail && typeof mail.close === 'function') {
|
|
3684
|
+
await mail.close();
|
|
3685
|
+
}
|
|
3686
|
+
await closeDatabase(database);
|
|
3687
|
+
events.removeAll();
|
|
3688
|
+
logger.info('AegisNode server stopped.');
|
|
3689
|
+
},
|
|
3690
|
+
};
|
|
3691
|
+
}
|
|
3692
|
+
|
|
3693
|
+
export async function runProject({ rootDir = process.cwd(), overrides = {}, startupSource = 'direct' } = {}) {
|
|
3694
|
+
const resolvedRoot = path.resolve(rootDir);
|
|
3695
|
+
const config = await loadProjectConfig(resolvedRoot);
|
|
3696
|
+
const env = String(config?.env || process.env.NODE_ENV || 'development').trim().toLowerCase();
|
|
3697
|
+
|
|
3698
|
+
if (env === 'development' && startupSource !== 'runserver') {
|
|
3699
|
+
throw new Error(
|
|
3700
|
+
`Development mode must be started with "aegisnode runserver". Received startup source "${startupSource}". "node app.js" and "node loader.cjs" are blocked in development.`,
|
|
3701
|
+
);
|
|
3702
|
+
}
|
|
3703
|
+
|
|
3704
|
+
if (env !== 'development' && startupSource === 'runserver') {
|
|
3705
|
+
throw new Error(
|
|
3706
|
+
`aegisnode runserver is available only in development mode. Resolved env="${env}". Use "node loader.cjs" for non-development startup.`,
|
|
3707
|
+
);
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3710
|
+
const kernel = await createKernel({ rootDir: resolvedRoot, overrides });
|
|
3711
|
+
await kernel.start();
|
|
3712
|
+
return kernel;
|
|
3713
|
+
}
|