underpost 2.8.883 → 2.8.885
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/README.md +4 -116
- package/bin/deploy.js +9 -10
- package/bin/file.js +4 -6
- package/cli.md +15 -11
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
- package/package.json +1 -1
- package/src/api/user/user.service.js +3 -10
- package/src/cli/cluster.js +21 -0
- package/src/cli/cron.js +8 -0
- package/src/cli/db.js +63 -1
- package/src/cli/deploy.js +156 -3
- package/src/cli/env.js +43 -0
- package/src/cli/fs.js +94 -0
- package/src/cli/image.js +8 -0
- package/src/cli/index.js +17 -4
- package/src/cli/monitor.js +0 -1
- package/src/cli/repository.js +95 -2
- package/src/client/components/core/Css.js +16 -0
- package/src/client/components/core/Docs.js +5 -13
- package/src/client/components/core/Modal.js +57 -39
- package/src/client/components/core/Router.js +6 -3
- package/src/client/components/core/Worker.js +205 -118
- package/src/client/components/default/MenuDefault.js +1 -0
- package/src/client.dev.js +6 -3
- package/src/db/DataBaseProvider.js +65 -12
- package/src/db/mariadb/MariaDB.js +39 -6
- package/src/db/mongo/MongooseDB.js +51 -133
- package/src/index.js +1 -1
- package/src/mailer/EmailRender.js +58 -9
- package/src/mailer/MailerProvider.js +98 -25
- package/src/runtime/express/Express.js +248 -0
- package/src/runtime/lampp/Lampp.js +27 -8
- package/src/server/auth.js +82 -43
- package/src/server/client-build-live.js +14 -5
- package/src/server/client-dev-server.js +21 -8
- package/src/server/conf.js +78 -25
- package/src/server/peer.js +2 -2
- package/src/server/runtime.js +49 -208
- package/src/server/start.js +39 -0
- package/src/ws/IoInterface.js +132 -39
- package/src/ws/IoServer.js +79 -31
- package/src/ws/core/core.ws.connection.js +50 -16
- package/src/ws/core/core.ws.emit.js +47 -8
- package/src/ws/core/core.ws.server.js +62 -10
- package/src/runtime/nginx/Nginx.js +0 -3
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A service dedicated to creating and configuring an Express.js application
|
|
3
|
+
* instance based on server configuration data.
|
|
4
|
+
* @module src/runtime/express/Express.js
|
|
5
|
+
* @namespace ExpressService
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs-extra';
|
|
9
|
+
import express from 'express';
|
|
10
|
+
import fileUpload from 'express-fileupload';
|
|
11
|
+
import swaggerUi from 'swagger-ui-express';
|
|
12
|
+
import compression from 'compression';
|
|
13
|
+
import { createServer } from 'http';
|
|
14
|
+
|
|
15
|
+
import UnderpostStartUp from '../../server/start.js';
|
|
16
|
+
import { loggerFactory, loggerMiddleware } from '../../server/logger.js';
|
|
17
|
+
import { getCapVariableName, newInstance } from '../../client/components/core/CommonJs.js';
|
|
18
|
+
import { MailerProvider } from '../../mailer/MailerProvider.js';
|
|
19
|
+
import { DataBaseProvider } from '../../db/DataBaseProvider.js';
|
|
20
|
+
import { createPeerServer } from '../../server/peer.js';
|
|
21
|
+
import { createValkeyConnection } from '../../server/valkey.js';
|
|
22
|
+
import { applySecurity, authMiddlewareFactory } from '../../server/auth.js';
|
|
23
|
+
import { ssrMiddlewareFactory } from '../../server/ssr.js';
|
|
24
|
+
|
|
25
|
+
const logger = loggerFactory(import.meta);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @class ExpressService
|
|
29
|
+
* @description A service dedicated to creating and configuring an Express.js application
|
|
30
|
+
* instance based on server configuration data.
|
|
31
|
+
* @memberof ExpressService
|
|
32
|
+
*/
|
|
33
|
+
class ExpressService {
|
|
34
|
+
/**
|
|
35
|
+
* Creates and configures a complete Express application instance for a specific host/path configuration.
|
|
36
|
+
*
|
|
37
|
+
* @method createApp
|
|
38
|
+
* @memberof ExpressService
|
|
39
|
+
* @param {string} config.host - The host name for the instance (e.g., 'www.example.com').
|
|
40
|
+
* @param {string} config.path - The URL path for the instance (e.g., '/', '/api/v1').
|
|
41
|
+
* @param {number} config.port - The primary listening port for the instance.
|
|
42
|
+
* @param {string} config.client - The client associated with the instance (used for SSR/Mailer configuration lookup).
|
|
43
|
+
* @param {string[]} [config.apis] - A list of API names to load and attach routers for.
|
|
44
|
+
* @param {string[]} config.origins - Allowed origins for CORS.
|
|
45
|
+
* @param {string} [config.directory] - The directory for static files (if overriding default).
|
|
46
|
+
* @param {string} [config.ws] - The WebSocket server name to use.
|
|
47
|
+
* @param {object} [config.mailer] - Mailer configuration.
|
|
48
|
+
* @param {object} [config.db] - Database configuration.
|
|
49
|
+
* @param {string} [config.redirect] - URL or flag to indicate an HTTP redirect should be configured.
|
|
50
|
+
* @param {boolean} [config.peer] - Whether to enable the peer server.
|
|
51
|
+
* @param {object} [config.valkey] - Valkey connection configuration.
|
|
52
|
+
* @param {string} [config.apiBaseHost] - Base host for the API (if running separate API).
|
|
53
|
+
* @param {string} config.redirectTarget - The full target URL for redirection (used if `redirect` is true).
|
|
54
|
+
* @param {string} config.rootHostPath - The root path for public host assets (e.g., `/public/hostname`).
|
|
55
|
+
* @param {object} config.confSSR - The SSR configuration object, used to look up Mailer templates.
|
|
56
|
+
* @param {import('prom-client').Counter<string>} config.promRequestCounter - Prometheus request counter instance.
|
|
57
|
+
* @param {import('prom-client').Registry} config.promRegister - Prometheus register instance for metrics.
|
|
58
|
+
* @returns {Promise<{portsUsed: number}>} An object indicating how many additional ports were used (e.g., for PeerServer).
|
|
59
|
+
*/
|
|
60
|
+
async createApp({
|
|
61
|
+
host,
|
|
62
|
+
path,
|
|
63
|
+
port,
|
|
64
|
+
client,
|
|
65
|
+
apis,
|
|
66
|
+
origins,
|
|
67
|
+
directory,
|
|
68
|
+
ws,
|
|
69
|
+
mailer,
|
|
70
|
+
db,
|
|
71
|
+
redirect,
|
|
72
|
+
peer,
|
|
73
|
+
valkey,
|
|
74
|
+
apiBaseHost,
|
|
75
|
+
redirectTarget,
|
|
76
|
+
rootHostPath,
|
|
77
|
+
confSSR,
|
|
78
|
+
promRequestCounter,
|
|
79
|
+
promRegister,
|
|
80
|
+
}) {
|
|
81
|
+
let portsUsed = 0;
|
|
82
|
+
const runningData = {
|
|
83
|
+
host,
|
|
84
|
+
path,
|
|
85
|
+
runtime: 'nodejs',
|
|
86
|
+
client,
|
|
87
|
+
meta: import.meta,
|
|
88
|
+
apis,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const app = express();
|
|
92
|
+
|
|
93
|
+
if (process.env.NODE_ENV === 'production') app.set('trust proxy', true);
|
|
94
|
+
|
|
95
|
+
app.use((req, res, next) => {
|
|
96
|
+
res.on('finish', () => {
|
|
97
|
+
promRequestCounter.inc({
|
|
98
|
+
instance: `${host}:${port}${path}`,
|
|
99
|
+
method: req.method,
|
|
100
|
+
status_code: res.statusCode,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
return next();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Metrics endpoint
|
|
107
|
+
app.get(`${path === '/' ? '' : path}/metrics`, async (req, res) => {
|
|
108
|
+
res.set('Content-Type', promRegister.contentType);
|
|
109
|
+
return res.end(await promRegister.metrics());
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Logging, Compression, and Body Parsers
|
|
113
|
+
app.use(loggerMiddleware(import.meta));
|
|
114
|
+
// Compression filter logic is correctly inlined here
|
|
115
|
+
app.use(compression({ filter: (req, res) => !req.headers['x-no-compression'] && compression.filter(req, res) }));
|
|
116
|
+
app.use(express.json({ limit: '100MB' }));
|
|
117
|
+
app.use(express.urlencoded({ extended: true, limit: '20MB' }));
|
|
118
|
+
app.use(fileUpload());
|
|
119
|
+
|
|
120
|
+
if (process.env.NODE_ENV === 'development') app.set('json spaces', 2);
|
|
121
|
+
|
|
122
|
+
// Language handling middleware
|
|
123
|
+
app.use((req, res, next) => {
|
|
124
|
+
const lang = req.headers['accept-language'] || 'en';
|
|
125
|
+
req.lang = typeof lang === 'string' && lang.toLowerCase().match('es') ? 'es' : 'en';
|
|
126
|
+
return next();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Static file serving
|
|
130
|
+
app.use('/', express.static(directory ? directory : `.${rootHostPath}`));
|
|
131
|
+
|
|
132
|
+
// Handle redirection-only instances
|
|
133
|
+
if (redirect) {
|
|
134
|
+
app.use((req, res, next) => {
|
|
135
|
+
if (process.env.NODE_ENV === 'production' && !req.url.startsWith(`/.well-known/acme-challenge`)) {
|
|
136
|
+
return res.status(302).redirect(redirectTarget + req.url);
|
|
137
|
+
}
|
|
138
|
+
return next();
|
|
139
|
+
});
|
|
140
|
+
await UnderpostStartUp.API.listenPortController(app, port, runningData);
|
|
141
|
+
return { portsUsed };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Create HTTP server for regular instances (required for WebSockets)
|
|
145
|
+
const server = createServer({}, app);
|
|
146
|
+
|
|
147
|
+
if (!apiBaseHost) {
|
|
148
|
+
// Swagger path definition
|
|
149
|
+
const swaggerJsonPath = `./public/${host}${path === '/' ? path : `${path}/`}swagger-output.json`;
|
|
150
|
+
const swaggerPath = `${path === '/' ? `/api-docs` : `${path}/api-docs`}`;
|
|
151
|
+
|
|
152
|
+
// Flag swagger requests before security middleware
|
|
153
|
+
if (fs.existsSync(swaggerJsonPath)) {
|
|
154
|
+
app.use(swaggerPath, (req, res, next) => {
|
|
155
|
+
res.locals.isSwagger = true;
|
|
156
|
+
next();
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Swagger UI setup
|
|
161
|
+
if (fs.existsSync(swaggerJsonPath)) {
|
|
162
|
+
const swaggerDoc = JSON.parse(fs.readFileSync(swaggerJsonPath, 'utf8'));
|
|
163
|
+
// Reusing swaggerPath defined outside, removing unnecessary redeclaration
|
|
164
|
+
app.use(swaggerPath, swaggerUi.serve, swaggerUi.setup(swaggerDoc));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Security and CORS
|
|
168
|
+
applySecurity(app, {
|
|
169
|
+
origin: origins,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Database and Valkey connections
|
|
173
|
+
if (db && apis) await DataBaseProvider.load({ apis, host, path, db });
|
|
174
|
+
if (valkey) await createValkeyConnection({ host, path }, valkey);
|
|
175
|
+
|
|
176
|
+
// Mailer setup
|
|
177
|
+
if (mailer) {
|
|
178
|
+
const mailerSsrConf = confSSR[getCapVariableName(client)];
|
|
179
|
+
await MailerProvider.load({
|
|
180
|
+
id: `${host}${path}`,
|
|
181
|
+
meta: `mailer-${host}${path}`,
|
|
182
|
+
host,
|
|
183
|
+
path,
|
|
184
|
+
...mailer,
|
|
185
|
+
templates: mailerSsrConf ? mailerSsrConf.mailer : {},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// API router loading
|
|
190
|
+
if (apis && apis.length > 0) {
|
|
191
|
+
const authMiddleware = authMiddlewareFactory({ host, path });
|
|
192
|
+
const apiPath = `${path === '/' ? '' : path}/${process.env.BASE_API}`;
|
|
193
|
+
for (const api of apis) {
|
|
194
|
+
logger.info(`Build api server`, `${host}${apiPath}/${api}`);
|
|
195
|
+
const { ApiRouter } = await import(`../../api/${api}/${api}.router.js`);
|
|
196
|
+
const router = ApiRouter({ host, path, apiPath, mailer, db, authMiddleware, origins });
|
|
197
|
+
app.use(`${apiPath}/${api}`, router);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// WebSocket server setup
|
|
202
|
+
if (ws) {
|
|
203
|
+
const { createIoServer } = await import(`../../ws/${ws}/${ws}.ws.server.js`);
|
|
204
|
+
const { options, meta, ioServer } = await createIoServer(server, { host, path, db, port, origins });
|
|
205
|
+
|
|
206
|
+
// Listen on the main port for the WS server
|
|
207
|
+
await UnderpostStartUp.API.listenPortController(ioServer, port, {
|
|
208
|
+
runtime: 'nodejs',
|
|
209
|
+
client: null,
|
|
210
|
+
host,
|
|
211
|
+
path: options.path,
|
|
212
|
+
meta,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Peer server setup
|
|
217
|
+
if (peer) {
|
|
218
|
+
portsUsed++; // Peer server uses one additional port
|
|
219
|
+
const peerPort = newInstance(port + portsUsed); // portsUsed is 1 here
|
|
220
|
+
const { options, meta, peerServer } = await createPeerServer({
|
|
221
|
+
port: peerPort,
|
|
222
|
+
devPort: port,
|
|
223
|
+
origins,
|
|
224
|
+
host,
|
|
225
|
+
path,
|
|
226
|
+
});
|
|
227
|
+
await UnderpostStartUp.API.listenPortController(peerServer, peerPort, {
|
|
228
|
+
runtime: 'nodejs',
|
|
229
|
+
client: null,
|
|
230
|
+
host,
|
|
231
|
+
path: options.path,
|
|
232
|
+
meta,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// SSR middleware loading
|
|
238
|
+
const ssr = await ssrMiddlewareFactory({ app, directory, rootHostPath, path });
|
|
239
|
+
for (const [_, ssrMiddleware] of Object.entries(ssr)) app.use(ssrMiddleware);
|
|
240
|
+
|
|
241
|
+
// Start listening on the main port
|
|
242
|
+
await UnderpostStartUp.API.listenPortController(server, port, runningData);
|
|
243
|
+
|
|
244
|
+
return { portsUsed };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export default new ExpressService();
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exported singleton instance of the LamppService class.
|
|
3
|
+
* This object is used to interact with the Lampp configuration and service.
|
|
4
|
+
* @module src/runtime/lampp/Lampp.js
|
|
5
|
+
* @namespace LamppService
|
|
6
|
+
*/
|
|
7
|
+
|
|
1
8
|
import fs from 'fs-extra';
|
|
2
9
|
import { getRootDirectory, shellCd, shellExec } from '../../server/process.js';
|
|
3
10
|
import { loggerFactory } from '../../server/logger.js';
|
|
@@ -9,12 +16,14 @@ const logger = loggerFactory(import.meta);
|
|
|
9
16
|
* @description Provides utilities for managing the XAMPP (Lampp) service on Linux,
|
|
10
17
|
* including initialization, router configuration, and virtual host creation.
|
|
11
18
|
* It manages the server's configuration files and controls the service process.
|
|
19
|
+
* @memberof LamppService
|
|
12
20
|
*/
|
|
13
21
|
class LamppService {
|
|
14
22
|
/**
|
|
15
23
|
* @private
|
|
16
24
|
* @type {string | undefined}
|
|
17
25
|
* @description Stores the accumulated Apache virtual host configuration (router definition).
|
|
26
|
+
* @memberof LamppService
|
|
18
27
|
*/
|
|
19
28
|
router;
|
|
20
29
|
|
|
@@ -22,12 +31,15 @@ class LamppService {
|
|
|
22
31
|
* @public
|
|
23
32
|
* @type {number[]}
|
|
24
33
|
* @description A list of ports currently configured and listened to by the Lampp service.
|
|
34
|
+
* @memberof LamppService
|
|
25
35
|
*/
|
|
26
36
|
ports;
|
|
27
37
|
|
|
28
38
|
/**
|
|
29
39
|
* Creates an instance of LamppService.
|
|
30
40
|
* Initializes the router configuration and ports list.
|
|
41
|
+
* @method constructor
|
|
42
|
+
* @memberof LamppService
|
|
31
43
|
*/
|
|
32
44
|
constructor() {
|
|
33
45
|
this.router = undefined;
|
|
@@ -37,8 +49,11 @@ class LamppService {
|
|
|
37
49
|
/**
|
|
38
50
|
* Checks if the XAMPP (Lampp) service appears to be installed based on the presence of its main configuration file.
|
|
39
51
|
*
|
|
52
|
+
* @method enabled
|
|
40
53
|
* @memberof LamppService
|
|
41
54
|
* @returns {boolean} True if the configuration file exists, indicating Lampp is likely installed.
|
|
55
|
+
* @throws {Error} If the configuration file does not exist.
|
|
56
|
+
* @memberof LamppService
|
|
42
57
|
*/
|
|
43
58
|
enabled() {
|
|
44
59
|
return fs.existsSync('/opt/lampp/etc/httpd.conf');
|
|
@@ -49,10 +64,11 @@ class LamppService {
|
|
|
49
64
|
* This method configures virtual hosts, disables default ports (80/443) in the main config
|
|
50
65
|
* to avoid conflicts, and starts or stops the service using shell commands.
|
|
51
66
|
*
|
|
52
|
-
* @
|
|
67
|
+
* @method initService
|
|
53
68
|
* @param {object} [options={daemon: false}] - Options for service initialization.
|
|
54
69
|
* @param {boolean} [options.daemon=false] - Flag to indicate if the service should be run as a daemon (currently unused in logic).
|
|
55
70
|
* @returns {Promise<void>}
|
|
71
|
+
* @memberof LamppService
|
|
56
72
|
*/
|
|
57
73
|
async initService(options = { daemon: false }) {
|
|
58
74
|
let cmd;
|
|
@@ -117,9 +133,10 @@ class LamppService {
|
|
|
117
133
|
* Appends new Apache VirtualHost configuration content to the internal router string.
|
|
118
134
|
* If a router config file exists from a previous run, it loads it first.
|
|
119
135
|
*
|
|
120
|
-
* @
|
|
136
|
+
* @method appendRouter
|
|
121
137
|
* @param {string} render - The new VirtualHost configuration string to append.
|
|
122
138
|
* @returns {string} The complete, updated router configuration string.
|
|
139
|
+
* @memberof LamppService
|
|
123
140
|
*/
|
|
124
141
|
appendRouter(render) {
|
|
125
142
|
if (!this.router) {
|
|
@@ -135,7 +152,7 @@ class LamppService {
|
|
|
135
152
|
/**
|
|
136
153
|
* Resets the internal router configuration and removes the temporary configuration file.
|
|
137
154
|
*
|
|
138
|
-
* @memberof LamppService
|
|
155
|
+
* @memberof LamppService
|
|
139
156
|
* @returns {void}
|
|
140
157
|
*/
|
|
141
158
|
removeRouter() {
|
|
@@ -148,8 +165,9 @@ class LamppService {
|
|
|
148
165
|
* This includes downloading the installer, running it, and setting up initial configurations.
|
|
149
166
|
* Only runs on the 'linux' platform.
|
|
150
167
|
*
|
|
151
|
-
* @
|
|
168
|
+
* @method install
|
|
152
169
|
* @returns {Promise<void>}
|
|
170
|
+
* @memberof LamppService
|
|
153
171
|
*/
|
|
154
172
|
async install() {
|
|
155
173
|
if (process.platform === 'linux') {
|
|
@@ -199,7 +217,7 @@ class LamppService {
|
|
|
199
217
|
* Creates and appends a new Apache VirtualHost entry to the router configuration for a web application.
|
|
200
218
|
* The router is then applied by calling {@link LamppService#initService}.
|
|
201
219
|
*
|
|
202
|
-
* @
|
|
220
|
+
* @method createApp
|
|
203
221
|
* @param {object} options - Configuration options for the new web application.
|
|
204
222
|
* @param {number} options.port - The port the VirtualHost should listen on.
|
|
205
223
|
* @param {string} options.host - The ServerName/host for the VirtualHost.
|
|
@@ -210,6 +228,7 @@ class LamppService {
|
|
|
210
228
|
* @param {string} [options.redirectTarget] - The target URL for redirection.
|
|
211
229
|
* @param {boolean} [options.resetRouter] - If true, clears the existing router configuration before appending the new one.
|
|
212
230
|
* @returns {{disabled: boolean}} An object indicating if the service is disabled.
|
|
231
|
+
* @memberof LamppService
|
|
213
232
|
*/
|
|
214
233
|
createApp({ port, host, path, directory, rootHostPath, redirect, redirectTarget, resetRouter }) {
|
|
215
234
|
if (!this.enabled()) return { disabled: true };
|
|
@@ -262,13 +281,15 @@ Listen ${port}
|
|
|
262
281
|
}
|
|
263
282
|
|
|
264
283
|
/**
|
|
265
|
-
* @namespace LamppService
|
|
266
284
|
* @description Exported singleton instance of the LamppService class.
|
|
267
285
|
* This object is used to interact with the Lampp configuration and service.
|
|
286
|
+
* @memberof LamppService
|
|
268
287
|
* @type {LamppService}
|
|
269
288
|
*/
|
|
270
289
|
const Lampp = new LamppService();
|
|
271
290
|
|
|
291
|
+
export { Lampp };
|
|
292
|
+
|
|
272
293
|
// -- helper info --
|
|
273
294
|
|
|
274
295
|
// ERR too many redirects:
|
|
@@ -320,5 +341,3 @@ const Lampp = new LamppService();
|
|
|
320
341
|
// RewriteCond %{HTTP_HOST} ^www\. [NC]
|
|
321
342
|
// RewriteCond %{HTTP_HOST} ^(?:www\.)?(.+)$ [NC]
|
|
322
343
|
// RewriteRule ^ https://%1%{REQUEST_URI} [L,NE,R=301]
|
|
323
|
-
|
|
324
|
-
export { Lampp };
|
package/src/server/auth.js
CHANGED
|
@@ -110,16 +110,22 @@ function jwtIssuerAudienceFactory(options = { host: '', path: '' }) {
|
|
|
110
110
|
* @param {object} [options={}] Additional JWT sign options.
|
|
111
111
|
* @param {string} options.host The host name.
|
|
112
112
|
* @param {string} options.path The path name.
|
|
113
|
-
* @param {number}
|
|
113
|
+
* @param {number} accessExpireMinutes The access token expiration in minutes.
|
|
114
|
+
* @param {number} refreshExpireMinutes The refresh token expiration in minutes.
|
|
114
115
|
* @returns {string} The signed JWT.
|
|
115
116
|
* @throws {Error} If JWT key is not configured.
|
|
116
117
|
* @memberof Auth
|
|
117
118
|
*/
|
|
118
|
-
function jwtSign(
|
|
119
|
+
function jwtSign(
|
|
120
|
+
payload,
|
|
121
|
+
options = { host: '', path: '' },
|
|
122
|
+
accessExpireMinutes = process.env.ACCESS_EXPIRE_MINUTES,
|
|
123
|
+
refreshExpireMinutes = process.env.REFRESH_EXPIRE_MINUTES,
|
|
124
|
+
) {
|
|
119
125
|
const { issuer, audience } = jwtIssuerAudienceFactory(options);
|
|
120
126
|
const signOptions = {
|
|
121
127
|
algorithm: config.jwtAlgorithm,
|
|
122
|
-
expiresIn: `${
|
|
128
|
+
expiresIn: `${accessExpireMinutes}m`,
|
|
123
129
|
issuer,
|
|
124
130
|
audience,
|
|
125
131
|
};
|
|
@@ -128,7 +134,11 @@ function jwtSign(payload, options = { host: '', path: '' }, expireMinutes = proc
|
|
|
128
134
|
|
|
129
135
|
if (!process.env.JWT_SECRET) throw new Error('JWT key not configured');
|
|
130
136
|
|
|
131
|
-
|
|
137
|
+
// Add refreshExpiresAt to the payload, which is the same as the token's own expiry.
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
payload.refreshExpiresAt = now + refreshExpireMinutes * 60 * 1000;
|
|
140
|
+
|
|
141
|
+
logger.info('JWT signed', { payload, signOptions, accessExpireMinutes, refreshExpireMinutes });
|
|
132
142
|
|
|
133
143
|
return jwt.sign(payload, process.env.JWT_SECRET, signOptions);
|
|
134
144
|
}
|
|
@@ -170,6 +180,14 @@ const getBearerToken = (req) => {
|
|
|
170
180
|
return '';
|
|
171
181
|
};
|
|
172
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Checks if the request is a refresh token request.
|
|
185
|
+
* @param {import('express').Request} req The Express request object.
|
|
186
|
+
* @returns {boolean} True if the request is a refresh token request, false otherwise.
|
|
187
|
+
* @memberof Auth
|
|
188
|
+
*/
|
|
189
|
+
const isRefreshTokenReq = (req) => req.method === 'GET' && req.params.id === 'auth';
|
|
190
|
+
|
|
173
191
|
// ---------- Middleware ----------
|
|
174
192
|
/**
|
|
175
193
|
* Creates a middleware to authenticate requests using a JWT Bearer token.
|
|
@@ -237,10 +255,7 @@ const authMiddlewareFactory = (options = { host: '', path: '' }) => {
|
|
|
237
255
|
return res.status(401).json({ status: 'error', message: 'unauthorized: host or path mismatch' });
|
|
238
256
|
}
|
|
239
257
|
|
|
240
|
-
|
|
241
|
-
const isRefreshTokenReq = req.method === 'GET' && req.params.id === 'auth';
|
|
242
|
-
|
|
243
|
-
if (!isRefreshTokenReq && session.expiresAt < new Date()) {
|
|
258
|
+
if (!isRefreshTokenReq(req) && session.expiresAt < new Date()) {
|
|
244
259
|
return res.status(401).json({ status: 'error', message: 'unauthorized: session expired' });
|
|
245
260
|
}
|
|
246
261
|
}
|
|
@@ -310,53 +325,32 @@ const validatePasswordMiddleware = (req) => {
|
|
|
310
325
|
/**
|
|
311
326
|
* Creates cookie options for the refresh token.
|
|
312
327
|
* @param {import('express').Request} req The Express request object.
|
|
328
|
+
* @param {string} host The host name.
|
|
313
329
|
* @returns {object} Cookie options.
|
|
314
330
|
* @memberof Auth
|
|
315
331
|
*/
|
|
316
|
-
const cookieOptionsFactory = (req) => {
|
|
332
|
+
const cookieOptionsFactory = (req, host) => {
|
|
317
333
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
318
334
|
|
|
319
|
-
// Determine hostname safely:
|
|
320
|
-
// Prefer origin header if present (it contains protocol + host)
|
|
321
|
-
let candidateHost = undefined;
|
|
322
|
-
try {
|
|
323
|
-
if (req.headers && req.headers.origin) {
|
|
324
|
-
candidateHost = new URL(req.headers.origin).hostname;
|
|
325
|
-
}
|
|
326
|
-
} catch (e) {
|
|
327
|
-
/* ignore parse error */
|
|
328
|
-
logger.error(e);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// fallback to req.hostname (Express sets this; ensure trust proxy if behind proxy)
|
|
332
|
-
if (!candidateHost) candidateHost = (req.hostname || '').split(':')[0];
|
|
333
|
-
|
|
334
|
-
candidateHost = (candidateHost || '').trim().replace(/^www\./i, '');
|
|
335
|
-
|
|
336
|
-
// Do not set domain for localhost, 127.x.x.x, or plain IPs
|
|
337
|
-
const isIpOrLocal = /^(localhost|127(?:\.\d+){0,2}\.\d+|\[::1\]|\d+\.\d+\.\d+\.\d+)$/i.test(candidateHost);
|
|
338
|
-
const domain = isProduction && candidateHost && !isIpOrLocal ? `.${candidateHost}` : undefined;
|
|
339
|
-
|
|
340
335
|
// Determine if request is secure: respect X-Forwarded-Proto when behind proxy
|
|
341
336
|
const forwardedProto = (req.headers && req.headers['x-forwarded-proto']) || '';
|
|
342
337
|
const reqIsSecure = Boolean(req.secure || forwardedProto.split(',')[0] === 'https');
|
|
343
338
|
|
|
344
339
|
// secure must be true for SameSite=None to work across sites
|
|
345
|
-
const secure = isProduction ? reqIsSecure :
|
|
340
|
+
const secure = isProduction ? reqIsSecure : req.protocol === 'https';
|
|
346
341
|
const sameSite = secure ? 'None' : 'Lax';
|
|
347
342
|
|
|
348
343
|
// Safe parse of maxAge minutes
|
|
349
|
-
const
|
|
350
|
-
const maxAge = Number.isFinite(minutes) && minutes > 0 ? minutes * 60 * 1000 : undefined;
|
|
344
|
+
const maxAge = parseInt(process.env.ACCESS_EXPIRE_MINUTES) * 60 * 1000;
|
|
351
345
|
|
|
352
346
|
const opts = {
|
|
353
347
|
httpOnly: true,
|
|
354
348
|
secure,
|
|
355
349
|
sameSite,
|
|
356
350
|
path: '/',
|
|
351
|
+
domain: process.env.NODE_ENV === 'production' ? host : 'localhost',
|
|
352
|
+
maxAge,
|
|
357
353
|
};
|
|
358
|
-
if (typeof maxAge !== 'undefined') opts.maxAge = maxAge;
|
|
359
|
-
if (domain) opts.domain = domain;
|
|
360
354
|
|
|
361
355
|
return opts;
|
|
362
356
|
};
|
|
@@ -394,11 +388,53 @@ async function createSessionAndUserToken(user, User, req, res, options = { host:
|
|
|
394
388
|
const jwtid = session._id.toString();
|
|
395
389
|
|
|
396
390
|
// Secure cookie settings
|
|
397
|
-
res.cookie('refreshToken', refreshToken, cookieOptionsFactory(req));
|
|
391
|
+
res.cookie('refreshToken', refreshToken, cookieOptionsFactory(req, options.host));
|
|
398
392
|
|
|
399
393
|
return { jwtid };
|
|
400
394
|
}
|
|
401
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Removes a session by its ID for a given user.
|
|
398
|
+
* @param {import('mongoose').Model} User The Mongoose User model.
|
|
399
|
+
* @param {string} userId The ID of the user.
|
|
400
|
+
* @param {string} sessionId The ID of the session to remove.
|
|
401
|
+
* @returns {Promise<void>}
|
|
402
|
+
* @memberof Auth
|
|
403
|
+
*/
|
|
404
|
+
async function removeSession(User, userId, sessionId) {
|
|
405
|
+
return await User.updateOne({ _id: userId }, { $pull: { activeSessions: { _id: sessionId } } });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Logs out a user session by removing it from the database and clearing the refresh token cookie.
|
|
410
|
+
* @param {import('mongoose').Model} User The Mongoose User model.
|
|
411
|
+
* @param {import('express').Request} req The Express request object.
|
|
412
|
+
* @param {import('express').Response} res The Express response object.
|
|
413
|
+
* @returns {Promise<boolean>} True if a session was found and removed, false otherwise.
|
|
414
|
+
* @memberof Auth
|
|
415
|
+
*/
|
|
416
|
+
async function logoutSession(User, req, res) {
|
|
417
|
+
const refreshToken = req.cookies?.refreshToken;
|
|
418
|
+
|
|
419
|
+
if (!refreshToken) {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const user = await User.findOne({ 'activeSessions.tokenHash': refreshToken });
|
|
424
|
+
|
|
425
|
+
if (!user) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const session = user.activeSessions.find((s) => s.tokenHash === refreshToken);
|
|
430
|
+
|
|
431
|
+
const result = await removeSession(User, user._id, session._id);
|
|
432
|
+
|
|
433
|
+
res.clearCookie('refreshToken', { path: '/' });
|
|
434
|
+
|
|
435
|
+
return result.modifiedCount > 0;
|
|
436
|
+
}
|
|
437
|
+
|
|
402
438
|
/**
|
|
403
439
|
* Create user and immediate session + access token
|
|
404
440
|
* @param {import('express').Request} req The Express request object.
|
|
@@ -455,6 +491,7 @@ async function refreshSessionAndToken(req, res, User, options = { host: '', path
|
|
|
455
491
|
|
|
456
492
|
if (!user) {
|
|
457
493
|
// Possible token reuse: look up user by some other signals? If not possible, log and throw.
|
|
494
|
+
// TODO: on cors requests, this will throw an error, because the cookie is not sent.
|
|
458
495
|
logger.warn('Refresh token reuse or invalid token detected');
|
|
459
496
|
// Optional: revoke by clearing cookie and returning unauthorized
|
|
460
497
|
res.clearCookie('refreshToken', { path: '/' });
|
|
@@ -470,12 +507,10 @@ async function refreshSessionAndToken(req, res, User, options = { host: '', path
|
|
|
470
507
|
}
|
|
471
508
|
|
|
472
509
|
// Check expiry
|
|
473
|
-
if (session.expiresAt && session.expiresAt < new Date()) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
res.clearCookie('refreshToken', { path: '/' });
|
|
478
|
-
throw new Error('Refresh token expired');
|
|
510
|
+
if (!isRefreshTokenReq(req) && session.expiresAt && session.expiresAt < new Date()) {
|
|
511
|
+
const result = await removeSession(User, user._id, session._id);
|
|
512
|
+
if (result) throw new Error('Refresh token expired');
|
|
513
|
+
else throw new Error('Session not found');
|
|
479
514
|
}
|
|
480
515
|
|
|
481
516
|
// Rotate: generate new token, update stored hash and metadata
|
|
@@ -488,7 +523,7 @@ async function refreshSessionAndToken(req, res, User, options = { host: '', path
|
|
|
488
523
|
|
|
489
524
|
logger.warn('Refreshed session for user ' + user.email);
|
|
490
525
|
|
|
491
|
-
res.cookie('refreshToken', refreshToken, cookieOptionsFactory(req));
|
|
526
|
+
res.cookie('refreshToken', refreshToken, cookieOptionsFactory(req, options.host));
|
|
492
527
|
|
|
493
528
|
return jwtSign(
|
|
494
529
|
UserDto.auth.payload(user, session._id.toString(), req.ip, req.headers['user-agent'], options.host, options.path),
|
|
@@ -608,6 +643,7 @@ function applySecurity(app, opts = {}) {
|
|
|
608
643
|
maxAge: 600,
|
|
609
644
|
}),
|
|
610
645
|
);
|
|
646
|
+
logger.info('Cors origin', origin);
|
|
611
647
|
|
|
612
648
|
// Rate limiting + slow down
|
|
613
649
|
const limiter = rateLimit({
|
|
@@ -646,5 +682,8 @@ export {
|
|
|
646
682
|
createSessionAndUserToken,
|
|
647
683
|
createUserAndSession,
|
|
648
684
|
refreshSessionAndToken,
|
|
685
|
+
logoutSession,
|
|
686
|
+
removeSession,
|
|
649
687
|
applySecurity,
|
|
688
|
+
isRefreshTokenReq,
|
|
650
689
|
};
|
|
@@ -8,18 +8,20 @@ const logger = loggerFactory(import.meta);
|
|
|
8
8
|
const clientLiveBuild = async () => {
|
|
9
9
|
if (fs.existsSync(`./tmp/client.build.json`)) {
|
|
10
10
|
const deployId = process.argv[2];
|
|
11
|
-
|
|
11
|
+
const subConf = process.argv[3];
|
|
12
12
|
let clientId = 'default';
|
|
13
13
|
let host = 'default.net';
|
|
14
14
|
let path = '/';
|
|
15
15
|
let baseHost = `${host}${path === '/' ? '' : path}`;
|
|
16
16
|
let views = Config.default.client[clientId].views;
|
|
17
|
+
let apiBaseHost;
|
|
18
|
+
let apiBaseProxyPath;
|
|
17
19
|
|
|
18
20
|
if (
|
|
19
21
|
deployId &&
|
|
20
22
|
(fs.existsSync(`./engine-private/conf/${deployId}`) || fs.existsSync(`./engine-private/replica/${deployId}`))
|
|
21
23
|
) {
|
|
22
|
-
loadConf(deployId);
|
|
24
|
+
loadConf(deployId, subConf);
|
|
23
25
|
const confClient = JSON.parse(
|
|
24
26
|
fs.readFileSync(
|
|
25
27
|
fs.existsSync(`./engine-private/replica/${deployId}`)
|
|
@@ -32,7 +34,9 @@ const clientLiveBuild = async () => {
|
|
|
32
34
|
);
|
|
33
35
|
const confServer = JSON.parse(
|
|
34
36
|
fs.readFileSync(
|
|
35
|
-
fs.existsSync(`./engine-private/
|
|
37
|
+
fs.existsSync(`./engine-private/conf/${deployId}/conf.server.dev.${subConf}.json`)
|
|
38
|
+
? `./engine-private/conf/${deployId}/conf.server.dev.${subConf}.json`
|
|
39
|
+
: fs.existsSync(`./engine-private/replica/${deployId}`)
|
|
36
40
|
? `./engine-private/replica/${deployId}/conf.server.json`
|
|
37
41
|
: fs.existsSync(`./engine-private/conf/${deployId}/conf.server.json`)
|
|
38
42
|
? `./engine-private/conf/${deployId}/conf.server.json`
|
|
@@ -40,20 +44,25 @@ const clientLiveBuild = async () => {
|
|
|
40
44
|
'utf8',
|
|
41
45
|
),
|
|
42
46
|
);
|
|
43
|
-
host = process.argv[
|
|
44
|
-
path = process.argv[
|
|
47
|
+
host = process.argv[4];
|
|
48
|
+
path = process.argv[5];
|
|
45
49
|
clientId = confServer[host][path].client;
|
|
46
50
|
views = confClient[clientId].views;
|
|
47
51
|
baseHost = `${host}${path === '/' ? '' : path}`;
|
|
52
|
+
apiBaseHost = confServer[host][path].apiBaseHost;
|
|
53
|
+
apiBaseProxyPath = confServer[host][path].apiBaseProxyPath;
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
logger.info('Live build config', {
|
|
51
57
|
deployId,
|
|
58
|
+
subConf,
|
|
52
59
|
host,
|
|
53
60
|
path,
|
|
54
61
|
clientId,
|
|
55
62
|
baseHost,
|
|
56
63
|
views: views.length,
|
|
64
|
+
apiBaseHost,
|
|
65
|
+
apiBaseProxyPath,
|
|
57
66
|
});
|
|
58
67
|
|
|
59
68
|
const updates = JSON.parse(fs.readFileSync(`./tmp/client.build.json`, 'utf8'));
|