ezpm2gui 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +321 -295
- package/bin/ezpm2gui.js +10 -10
- package/bin/ezpm2gui.ts +51 -51
- package/bin/generate-ecosystem.js +36 -36
- package/bin/generate-ecosystem.ts +56 -56
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/server/config/project-configs.json +236 -0
- package/dist/server/index.js +214 -25
- package/dist/server/routes/deployApplication.js +6 -5
- package/dist/server/routes/pageAuth.d.ts +3 -0
- package/dist/server/routes/pageAuth.js +177 -0
- package/dist/server/routes/remoteConnections.js +260 -0
- package/dist/server/routes/updates.d.ts +3 -0
- package/dist/server/routes/updates.js +135 -0
- package/dist/server/utils/remote-connection.d.ts +18 -0
- package/dist/server/utils/remote-connection.js +216 -9
- package/package.json +73 -71
- package/scripts/postinstall.js +36 -36
- package/src/client/build/asset-manifest.json +6 -6
- package/src/client/build/favicon.ico +2 -2
- package/src/client/build/index.html +1 -1
- package/src/client/build/logo192.svg +7 -7
- package/src/client/build/logo512.svg +7 -7
- package/src/client/build/manifest.json +24 -24
- package/src/client/build/static/css/main.775772ee.css +5 -0
- package/src/client/build/static/css/main.775772ee.css.map +1 -0
- package/src/client/build/static/js/main.cbcb09c9.js +3 -0
- package/src/client/build/static/js/main.cbcb09c9.js.map +1 -0
- package/dist/server/config/cron-jobs.json +0 -1
- package/dist/server/config/remote-connections.json +0 -3
- package/dist/server/daemon/ezpm2gui.err.log +0 -414
- package/dist/server/daemon/ezpm2gui.exe +0 -0
- package/dist/server/daemon/ezpm2gui.exe.config +0 -6
- package/dist/server/daemon/ezpm2gui.out.log +0 -289
- package/dist/server/daemon/ezpm2gui.wrapper.log +0 -172
- package/dist/server/daemon/ezpm2gui.xml +0 -32
- package/src/client/build/static/css/main.c506cba5.css +0 -5
- package/src/client/build/static/css/main.c506cba5.css.map +0 -1
- package/src/client/build/static/js/main.5278cddd.js +0 -3
- package/src/client/build/static/js/main.5278cddd.js.map +0 -1
- /package/src/client/build/static/js/{main.5278cddd.js.LICENSE.txt → main.cbcb09c9.js.LICENSE.txt} +0 -0
package/dist/server/index.js
CHANGED
|
@@ -4,10 +4,16 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.createServer = createServer;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
// @group Configuration : Load .env.local first (local overrides), then .env (defaults)
|
|
9
|
+
// Must happen before any code reads process.env
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
11
|
+
const dotenv = require('dotenv');
|
|
12
|
+
dotenv.config({ path: path_1.default.resolve(process.cwd(), '.env.local') });
|
|
13
|
+
dotenv.config({ path: path_1.default.resolve(process.cwd(), '.env') });
|
|
7
14
|
const express_1 = __importDefault(require("express"));
|
|
8
15
|
const http_1 = __importDefault(require("http"));
|
|
9
16
|
const socket_io_1 = require("socket.io");
|
|
10
|
-
const path_1 = __importDefault(require("path"));
|
|
11
17
|
const pm2_1 = __importDefault(require("pm2"));
|
|
12
18
|
const os_1 = __importDefault(require("os"));
|
|
13
19
|
const processConfig_1 = __importDefault(require("./routes/processConfig"));
|
|
@@ -15,6 +21,8 @@ const deployApplication_1 = __importDefault(require("./routes/deployApplication"
|
|
|
15
21
|
const modules_1 = __importDefault(require("./routes/modules"));
|
|
16
22
|
const remoteConnections_1 = __importDefault(require("./routes/remoteConnections"));
|
|
17
23
|
const cronJobs_1 = __importDefault(require("./routes/cronJobs"));
|
|
24
|
+
const updates_1 = __importDefault(require("./routes/updates"));
|
|
25
|
+
const pageAuth_1 = __importDefault(require("./routes/pageAuth"));
|
|
18
26
|
const logStreaming_1 = require("./routes/logStreaming");
|
|
19
27
|
const pm2_connection_1 = require("./utils/pm2-connection");
|
|
20
28
|
const remote_connection_1 = require("./utils/remote-connection");
|
|
@@ -58,6 +66,8 @@ function createServer() {
|
|
|
58
66
|
app.use('/api/modules', modules_1.default);
|
|
59
67
|
app.use('/api/remote', remoteConnections_1.default);
|
|
60
68
|
app.use('/api/cron-jobs', cronJobs_1.default);
|
|
69
|
+
app.use('/api/update', updates_1.default);
|
|
70
|
+
app.use('/api/auth', pageAuth_1.default);
|
|
61
71
|
// Setup log streaming with Socket.IO
|
|
62
72
|
(0, logStreaming_1.setupLogStreaming)(io); // PM2 API endpoints
|
|
63
73
|
app.get('/api/processes', async (req, res) => {
|
|
@@ -127,45 +137,224 @@ function createServer() {
|
|
|
127
137
|
};
|
|
128
138
|
res.json(metrics);
|
|
129
139
|
});
|
|
130
|
-
//
|
|
140
|
+
// @group LogHistory : Resolve log path from PM2 process descriptor
|
|
141
|
+
const resolveLocalLogPath = async (id, logType) => {
|
|
142
|
+
var _a, _b, _c;
|
|
143
|
+
const processDesc = await (0, pm2_connection_1.executePM2Command)((callback) => {
|
|
144
|
+
pm2_1.default.describe(id, callback);
|
|
145
|
+
});
|
|
146
|
+
if (!processDesc || processDesc.length === 0)
|
|
147
|
+
return null;
|
|
148
|
+
return (_c = (_b = (_a = processDesc[0]) === null || _a === void 0 ? void 0 : _a.pm2_env) === null || _b === void 0 ? void 0 : _b[`pm_${logType}_log_path`]) !== null && _c !== void 0 ? _c : null;
|
|
149
|
+
};
|
|
150
|
+
// @group LogHistory : Get log lines — ?lines=N (default 200, 0 = all)
|
|
131
151
|
app.get('/api/logs/:id/:type', async (req, res) => {
|
|
132
|
-
var _a, _b;
|
|
133
152
|
const { id, type } = req.params;
|
|
134
153
|
const logType = type === 'err' ? 'err' : 'out';
|
|
154
|
+
const lines = parseInt(req.query.lines || '200', 10);
|
|
135
155
|
try {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
156
|
+
const logPath = await resolveLocalLogPath(id, logType);
|
|
157
|
+
if (!logPath) {
|
|
158
|
+
res.status(404).json({ error: 'Process not found or log path unavailable' });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const fs = require('fs');
|
|
162
|
+
if (!fs.existsSync(logPath)) {
|
|
163
|
+
res.json({ logs: [], logPath });
|
|
141
164
|
return;
|
|
142
165
|
}
|
|
143
|
-
const
|
|
166
|
+
const { lines: result, total } = await streamTailLines(fs.createReadStream(logPath), lines);
|
|
167
|
+
res.json({ logs: result, logPath, totalLines: total });
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
console.error(`Error reading log file: ${err}`);
|
|
171
|
+
res.status(500).json({ error: 'Failed to read log file' });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
// @group LogHistory : Download full log file
|
|
175
|
+
app.get('/api/logs/:id/:type/download', async (req, res) => {
|
|
176
|
+
const { id, type } = req.params;
|
|
177
|
+
const logType = type === 'err' ? 'err' : 'out';
|
|
178
|
+
try {
|
|
179
|
+
const logPath = await resolveLocalLogPath(id, logType);
|
|
144
180
|
if (!logPath) {
|
|
145
|
-
res.status(404).json({ error:
|
|
181
|
+
res.status(404).json({ error: 'Process not found or log path unavailable' });
|
|
146
182
|
return;
|
|
147
183
|
}
|
|
148
184
|
const fs = require('fs');
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const fileSize = stats.size;
|
|
153
|
-
const readSize = Math.min(fileSize, 10 * 1024); // 10KB max
|
|
154
|
-
const position = Math.max(0, fileSize - readSize);
|
|
155
|
-
const buffer = Buffer.alloc(readSize);
|
|
156
|
-
const fd = fs.openSync(logPath, 'r');
|
|
157
|
-
fs.readSync(fd, buffer, 0, readSize, position);
|
|
158
|
-
fs.closeSync(fd);
|
|
159
|
-
logContent = buffer.toString('utf8');
|
|
185
|
+
if (!fs.existsSync(logPath)) {
|
|
186
|
+
res.status(404).json({ error: 'Log file does not exist yet' });
|
|
187
|
+
return;
|
|
160
188
|
}
|
|
161
|
-
const
|
|
162
|
-
res.
|
|
189
|
+
const fileName = `${id}-${logType}.log`;
|
|
190
|
+
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
|
191
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
192
|
+
fs.createReadStream(logPath).pipe(res);
|
|
163
193
|
}
|
|
164
194
|
catch (err) {
|
|
165
|
-
console.error(`Error
|
|
195
|
+
console.error(`Error downloading log file: ${err}`);
|
|
196
|
+
res.status(500).json({ error: 'Failed to download log file' });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
// @group LogHistory : List all log files (current + rotated) for a process
|
|
200
|
+
// Uses /api/log-files/:id to avoid Express matching /:id/:type with type='files'
|
|
201
|
+
app.get('/api/log-files/:id', async (req, res) => {
|
|
202
|
+
const { id } = req.params;
|
|
203
|
+
try {
|
|
204
|
+
const fs = require('fs');
|
|
205
|
+
const nodePath = require('path');
|
|
206
|
+
const [outPath, errPath] = await Promise.all([
|
|
207
|
+
resolveLocalLogPath(id, 'out'),
|
|
208
|
+
resolveLocalLogPath(id, 'err'),
|
|
209
|
+
]);
|
|
210
|
+
if (!outPath && !errPath) {
|
|
211
|
+
res.status(404).json({ error: 'Process not found or no log paths available' });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// Derive the process base name from the log path (strip -out.log suffix)
|
|
215
|
+
const baseName = outPath
|
|
216
|
+
? nodePath.basename(outPath).replace(/-out\.log.*$/, '')
|
|
217
|
+
: nodePath.basename(errPath).replace(/-(error|err)\.log.*$/, '');
|
|
218
|
+
const logDirs = new Set();
|
|
219
|
+
if (outPath)
|
|
220
|
+
logDirs.add(nodePath.dirname(outPath));
|
|
221
|
+
if (errPath)
|
|
222
|
+
logDirs.add(nodePath.dirname(errPath));
|
|
223
|
+
const files = [];
|
|
224
|
+
for (const dir of logDirs) {
|
|
225
|
+
if (!fs.existsSync(dir))
|
|
226
|
+
continue;
|
|
227
|
+
for (const fileName of fs.readdirSync(dir)) {
|
|
228
|
+
if (!fileName.startsWith(baseName))
|
|
229
|
+
continue;
|
|
230
|
+
const filePath = nodePath.join(dir, fileName);
|
|
231
|
+
const stat = fs.statSync(filePath);
|
|
232
|
+
if (!stat.isFile())
|
|
233
|
+
continue;
|
|
234
|
+
let type = 'unknown';
|
|
235
|
+
if (fileName.includes('-out'))
|
|
236
|
+
type = 'out';
|
|
237
|
+
else if (fileName.includes('-error') || fileName.includes('-err'))
|
|
238
|
+
type = 'err';
|
|
239
|
+
files.push({
|
|
240
|
+
name: fileName,
|
|
241
|
+
path: filePath,
|
|
242
|
+
size: stat.size,
|
|
243
|
+
modified: stat.mtime.toISOString(),
|
|
244
|
+
type,
|
|
245
|
+
compressed: fileName.endsWith('.gz'),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
files.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
|
250
|
+
res.json({ files });
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
console.error('Error listing log files:', err);
|
|
254
|
+
res.status(500).json({ error: 'Failed to list log files' });
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
// @group LogHistory : Security helper — block path traversal; allow any absolute log file path
|
|
258
|
+
const isAllowedLogPath = (filePath) => {
|
|
259
|
+
const nodePath = require('path');
|
|
260
|
+
const norm = nodePath.normalize(filePath);
|
|
261
|
+
// Require absolute path; reject shell-dangerous characters; require log extension
|
|
262
|
+
const SHELL_UNSAFE = /['"`;$|&<>(){}\\\n\r\0]/;
|
|
263
|
+
return (nodePath.isAbsolute(norm) &&
|
|
264
|
+
!norm.includes('..') &&
|
|
265
|
+
!SHELL_UNSAFE.test(norm) &&
|
|
266
|
+
/\.(log|gz)$/i.test(norm));
|
|
267
|
+
};
|
|
268
|
+
// @group LogHistory : Stream last N lines from a readable stream (ring buffer, no full-file load)
|
|
269
|
+
const streamTailLines = (inputStream, maxLines) => {
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
const rl = require('readline').createInterface({ input: inputStream, crlfDelay: Infinity });
|
|
272
|
+
const buffer = [];
|
|
273
|
+
let total = 0;
|
|
274
|
+
rl.on('line', (line) => {
|
|
275
|
+
if (line.trim() === '')
|
|
276
|
+
return;
|
|
277
|
+
total++;
|
|
278
|
+
if (maxLines > 0) {
|
|
279
|
+
buffer.push(line);
|
|
280
|
+
if (buffer.length > maxLines)
|
|
281
|
+
buffer.shift();
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
buffer.push(line);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
rl.on('close', () => resolve({ lines: buffer, total }));
|
|
288
|
+
rl.on('error', reject);
|
|
289
|
+
inputStream.on('error', reject);
|
|
290
|
+
});
|
|
291
|
+
};
|
|
292
|
+
// @group LogHistory : Read a specific log file by path — ?lines=N, supports .gz
|
|
293
|
+
// Uses /api/log-file (singular, top-level) to avoid clashing with /api/logs/:id/:type
|
|
294
|
+
app.get('/api/log-file', async (req, res) => {
|
|
295
|
+
const filePath = req.query.path;
|
|
296
|
+
const lines = parseInt(req.query.lines || '200', 10);
|
|
297
|
+
if (!filePath) {
|
|
298
|
+
res.status(400).json({ error: 'path query parameter required' });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (!isAllowedLogPath(filePath)) {
|
|
302
|
+
res.status(403).json({ error: 'Access denied: path is outside PM2 log directories' });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
const fs = require('fs');
|
|
307
|
+
const zlib = require('zlib');
|
|
308
|
+
if (!fs.existsSync(filePath)) {
|
|
309
|
+
res.status(404).json({ error: 'File not found' });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const inputStream = filePath.endsWith('.gz')
|
|
313
|
+
? fs.createReadStream(filePath).pipe(zlib.createGunzip())
|
|
314
|
+
: fs.createReadStream(filePath);
|
|
315
|
+
const { lines: result, total } = await streamTailLines(inputStream, lines);
|
|
316
|
+
res.json({ logs: result, totalLines: total });
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
console.error('Error reading log file:', err);
|
|
166
320
|
res.status(500).json({ error: 'Failed to read log file' });
|
|
167
321
|
}
|
|
168
322
|
});
|
|
323
|
+
// @group LogHistory : Download a specific log file by path (streams .gz as-is)
|
|
324
|
+
app.get('/api/log-file/download', async (req, res) => {
|
|
325
|
+
const filePath = req.query.path;
|
|
326
|
+
if (!filePath) {
|
|
327
|
+
res.status(400).json({ error: 'path query parameter required' });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (!isAllowedLogPath(filePath)) {
|
|
331
|
+
res.status(403).json({ error: 'Access denied' });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
const fs = require('fs');
|
|
336
|
+
const zlib = require('zlib');
|
|
337
|
+
const nodePath = require('path');
|
|
338
|
+
if (!fs.existsSync(filePath)) {
|
|
339
|
+
res.status(404).json({ error: 'File not found' });
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
// Decompress .gz server-side so the download is always plain text
|
|
343
|
+
const baseName = nodePath.basename(filePath).replace(/\.gz$/i, '');
|
|
344
|
+
res.setHeader('Content-Disposition', `attachment; filename="${baseName}"`);
|
|
345
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
346
|
+
if (filePath.endsWith('.gz')) {
|
|
347
|
+
fs.createReadStream(filePath).pipe(zlib.createGunzip()).pipe(res);
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
fs.createReadStream(filePath).pipe(res);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
console.error('Error downloading log file:', err);
|
|
355
|
+
res.status(500).json({ error: 'Failed to download file' });
|
|
356
|
+
}
|
|
357
|
+
});
|
|
169
358
|
// WebSocket for real-time updates
|
|
170
359
|
io.on('connection', (socket) => {
|
|
171
360
|
console.log('Client connected');
|
|
@@ -219,7 +408,7 @@ function createServer() {
|
|
|
219
408
|
}
|
|
220
409
|
// Only start the server if this file is run directly
|
|
221
410
|
if (require.main === module) {
|
|
222
|
-
const PORT = process.env.PORT ||
|
|
411
|
+
const PORT = process.env.PORT || 3101;
|
|
223
412
|
const HOST = process.env.HOST || 'localhost';
|
|
224
413
|
const server = createServer();
|
|
225
414
|
server.listen(PORT, () => {
|
|
@@ -11,7 +11,7 @@ const ProjectSetupService_1 = require("../services/ProjectSetupService");
|
|
|
11
11
|
const router = (0, express_1.Router)();
|
|
12
12
|
// Deploy a new application
|
|
13
13
|
router.post('/', async (req, res) => {
|
|
14
|
-
const { name, script, cwd, instances, exec_mode, autorestart, watch, max_memory_restart, env, appType, autoSetup = true } = req.body;
|
|
14
|
+
const { name, script, cwd, namespace, instances, exec_mode, autorestart, watch, max_memory_restart, env, appType, autoSetup = true } = req.body;
|
|
15
15
|
// Validate required fields
|
|
16
16
|
if (!name || !script) {
|
|
17
17
|
return res.status(400).json({ error: 'Name and script path are required' });
|
|
@@ -67,6 +67,7 @@ router.post('/', async (req, res) => {
|
|
|
67
67
|
name,
|
|
68
68
|
script,
|
|
69
69
|
cwd: projectPath,
|
|
70
|
+
namespace: namespace || 'default',
|
|
70
71
|
instances: parseInt(instances) || 1,
|
|
71
72
|
exec_mode: exec_mode || 'fork',
|
|
72
73
|
autorestart: autorestart !== undefined ? autorestart : true,
|
|
@@ -158,8 +159,8 @@ router.post('/generate-ecosystem', (req, res) => {
|
|
|
158
159
|
env: pm2Env.env || {}
|
|
159
160
|
};
|
|
160
161
|
});
|
|
161
|
-
const ecosystemConfig = `module.exports = {
|
|
162
|
-
apps: ${JSON.stringify(apps, null, 2)}
|
|
162
|
+
const ecosystemConfig = `module.exports = {
|
|
163
|
+
apps: ${JSON.stringify(apps, null, 2)}
|
|
163
164
|
};`;
|
|
164
165
|
// Create the file (either at specified path or default location)
|
|
165
166
|
const filePath = req.body.path || path_1.default.join(process.cwd(), 'ecosystem.config.js');
|
|
@@ -212,8 +213,8 @@ router.get('/generate-ecosystem-preview', (req, res) => {
|
|
|
212
213
|
env: pm2Env.env || {}
|
|
213
214
|
};
|
|
214
215
|
});
|
|
215
|
-
const ecosystemConfig = `module.exports = {
|
|
216
|
-
apps: ${JSON.stringify(apps, null, 2)}
|
|
216
|
+
const ecosystemConfig = `module.exports = {
|
|
217
|
+
apps: ${JSON.stringify(apps, null, 2)}
|
|
217
218
|
};`;
|
|
218
219
|
pm2_1.default.disconnect();
|
|
219
220
|
res.json({
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const express_1 = __importDefault(require("express"));
|
|
7
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
// @group Configuration : Path to the stored auth config file
|
|
11
|
+
const AUTH_FILE = path_1.default.join(__dirname, '../config/auth.json');
|
|
12
|
+
// @group Utilities : Load auth config — returns null when no password is set
|
|
13
|
+
function loadAuthConfig() {
|
|
14
|
+
try {
|
|
15
|
+
if (!fs_1.default.existsSync(AUTH_FILE))
|
|
16
|
+
return null;
|
|
17
|
+
const raw = fs_1.default.readFileSync(AUTH_FILE, 'utf8').trim();
|
|
18
|
+
if (!raw)
|
|
19
|
+
return null;
|
|
20
|
+
return JSON.parse(raw);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// @group Utilities : Persist auth config to disk
|
|
27
|
+
function saveAuthConfig(config) {
|
|
28
|
+
const dir = path_1.default.dirname(AUTH_FILE);
|
|
29
|
+
if (!fs_1.default.existsSync(dir))
|
|
30
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
31
|
+
fs_1.default.writeFileSync(AUTH_FILE, JSON.stringify(config), 'utf8');
|
|
32
|
+
}
|
|
33
|
+
// @group Utilities : Hash a plaintext password with PBKDF2 + salt
|
|
34
|
+
function hashPassword(password, salt) {
|
|
35
|
+
return crypto_1.default.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');
|
|
36
|
+
}
|
|
37
|
+
// @group Router : Express router for password-protection endpoints
|
|
38
|
+
const router = express_1.default.Router();
|
|
39
|
+
// @group Endpoints : GET /api/auth/status — is a password/PIN configured?
|
|
40
|
+
router.get('/status', (_req, res) => {
|
|
41
|
+
var _a;
|
|
42
|
+
const config = loadAuthConfig();
|
|
43
|
+
res.json({
|
|
44
|
+
passwordSet: config !== null,
|
|
45
|
+
pinSet: !!(config === null || config === void 0 ? void 0 : config.pinHash),
|
|
46
|
+
autoLockMinutes: (_a = config === null || config === void 0 ? void 0 : config.autoLockMinutes) !== null && _a !== void 0 ? _a : 0,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
// @group Endpoints : PATCH /api/auth/settings — update non-password settings (e.g. autoLockMinutes)
|
|
50
|
+
router.patch('/settings', (req, res) => {
|
|
51
|
+
var _a;
|
|
52
|
+
const { autoLockMinutes } = req.body;
|
|
53
|
+
const config = loadAuthConfig();
|
|
54
|
+
if (!config) {
|
|
55
|
+
return res.status(400).json({ success: false, error: 'No password set — configure a password first' });
|
|
56
|
+
}
|
|
57
|
+
const minutes = typeof autoLockMinutes === 'number' && autoLockMinutes >= 0 ? Math.floor(autoLockMinutes) : (_a = config.autoLockMinutes) !== null && _a !== void 0 ? _a : 0;
|
|
58
|
+
saveAuthConfig({ ...config, autoLockMinutes: minutes });
|
|
59
|
+
res.json({ success: true, autoLockMinutes: minutes });
|
|
60
|
+
});
|
|
61
|
+
// @group Endpoints : POST /api/auth/set — set or change the password
|
|
62
|
+
router.post('/set', (req, res) => {
|
|
63
|
+
var _a;
|
|
64
|
+
const { password, currentPassword } = req.body;
|
|
65
|
+
if (!password || typeof password !== 'string' || password.length < 4) {
|
|
66
|
+
return res.status(400).json({ success: false, error: 'Password must be at least 4 characters' });
|
|
67
|
+
}
|
|
68
|
+
const existing = loadAuthConfig();
|
|
69
|
+
// If a password is already set, require the current one before changing
|
|
70
|
+
if (existing) {
|
|
71
|
+
if (!currentPassword) {
|
|
72
|
+
return res.status(401).json({ success: false, error: 'Current password required to change password' });
|
|
73
|
+
}
|
|
74
|
+
const currentHash = hashPassword(currentPassword, existing.salt);
|
|
75
|
+
if (!crypto_1.default.timingSafeEqual(Buffer.from(currentHash, 'hex'), Buffer.from(existing.hash, 'hex'))) {
|
|
76
|
+
return res.status(401).json({ success: false, error: 'Current password is incorrect' });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const salt = crypto_1.default.randomBytes(32).toString('hex');
|
|
80
|
+
const hash = hashPassword(password, salt);
|
|
81
|
+
// Preserve PIN and autoLock settings when changing password
|
|
82
|
+
saveAuthConfig({
|
|
83
|
+
hash,
|
|
84
|
+
salt,
|
|
85
|
+
autoLockMinutes: (_a = existing === null || existing === void 0 ? void 0 : existing.autoLockMinutes) !== null && _a !== void 0 ? _a : 0,
|
|
86
|
+
...((existing === null || existing === void 0 ? void 0 : existing.pinHash) ? { pinHash: existing.pinHash, pinSalt: existing.pinSalt } : {}),
|
|
87
|
+
});
|
|
88
|
+
res.json({ success: true });
|
|
89
|
+
});
|
|
90
|
+
// @group Endpoints : POST /api/auth/verify — verify a password attempt
|
|
91
|
+
router.post('/verify', (req, res) => {
|
|
92
|
+
const { password } = req.body;
|
|
93
|
+
if (!password || typeof password !== 'string') {
|
|
94
|
+
return res.status(400).json({ success: false, error: 'Password is required' });
|
|
95
|
+
}
|
|
96
|
+
const config = loadAuthConfig();
|
|
97
|
+
if (!config) {
|
|
98
|
+
// No password set — treat as unlocked
|
|
99
|
+
return res.json({ success: true });
|
|
100
|
+
}
|
|
101
|
+
const hash = hashPassword(password, config.salt);
|
|
102
|
+
const match = crypto_1.default.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(config.hash, 'hex'));
|
|
103
|
+
if (!match) {
|
|
104
|
+
return res.status(401).json({ success: false, error: 'Incorrect password' });
|
|
105
|
+
}
|
|
106
|
+
res.json({ success: true });
|
|
107
|
+
});
|
|
108
|
+
// @group Endpoints : DELETE /api/auth/remove — remove the password (requires current password)
|
|
109
|
+
router.delete('/remove', (req, res) => {
|
|
110
|
+
const { password } = req.body;
|
|
111
|
+
const config = loadAuthConfig();
|
|
112
|
+
if (!config) {
|
|
113
|
+
return res.json({ success: true }); // nothing to remove
|
|
114
|
+
}
|
|
115
|
+
if (!password || typeof password !== 'string') {
|
|
116
|
+
return res.status(400).json({ success: false, error: 'Current password required' });
|
|
117
|
+
}
|
|
118
|
+
const hash = hashPassword(password, config.salt);
|
|
119
|
+
const match = crypto_1.default.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(config.hash, 'hex'));
|
|
120
|
+
if (!match) {
|
|
121
|
+
return res.status(401).json({ success: false, error: 'Incorrect password' });
|
|
122
|
+
}
|
|
123
|
+
fs_1.default.unlinkSync(AUTH_FILE);
|
|
124
|
+
res.json({ success: true });
|
|
125
|
+
});
|
|
126
|
+
// @group Endpoints : POST /api/auth/pin/set — set or change the PIN (4-digit)
|
|
127
|
+
router.post('/pin/set', (req, res) => {
|
|
128
|
+
const { pin } = req.body;
|
|
129
|
+
if (!pin || !/^\d{4}$/.test(pin)) {
|
|
130
|
+
return res.status(400).json({ success: false, error: 'PIN must be exactly 4 digits' });
|
|
131
|
+
}
|
|
132
|
+
const config = loadAuthConfig();
|
|
133
|
+
if (!config) {
|
|
134
|
+
return res.status(400).json({ success: false, error: 'Set a password first before adding a PIN' });
|
|
135
|
+
}
|
|
136
|
+
const pinSalt = crypto_1.default.randomBytes(32).toString('hex');
|
|
137
|
+
const pinHash = hashPassword(pin, pinSalt);
|
|
138
|
+
saveAuthConfig({ ...config, pinHash, pinSalt });
|
|
139
|
+
res.json({ success: true });
|
|
140
|
+
});
|
|
141
|
+
// @group Endpoints : POST /api/auth/pin/verify — verify a PIN attempt
|
|
142
|
+
router.post('/pin/verify', (req, res) => {
|
|
143
|
+
const { pin } = req.body;
|
|
144
|
+
if (!pin || typeof pin !== 'string') {
|
|
145
|
+
return res.status(400).json({ success: false, error: 'PIN is required' });
|
|
146
|
+
}
|
|
147
|
+
const config = loadAuthConfig();
|
|
148
|
+
if (!(config === null || config === void 0 ? void 0 : config.pinHash) || !(config === null || config === void 0 ? void 0 : config.pinSalt)) {
|
|
149
|
+
return res.status(400).json({ success: false, error: 'No PIN configured' });
|
|
150
|
+
}
|
|
151
|
+
const hash = hashPassword(pin, config.pinSalt);
|
|
152
|
+
const match = crypto_1.default.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(config.pinHash, 'hex'));
|
|
153
|
+
if (!match) {
|
|
154
|
+
return res.status(401).json({ success: false, error: 'Incorrect PIN' });
|
|
155
|
+
}
|
|
156
|
+
res.json({ success: true });
|
|
157
|
+
});
|
|
158
|
+
// @group Endpoints : DELETE /api/auth/pin/remove — remove PIN (requires current password)
|
|
159
|
+
router.delete('/pin/remove', (req, res) => {
|
|
160
|
+
const { password } = req.body;
|
|
161
|
+
const config = loadAuthConfig();
|
|
162
|
+
if (!config) {
|
|
163
|
+
return res.json({ success: true }); // nothing to remove
|
|
164
|
+
}
|
|
165
|
+
if (!password || typeof password !== 'string') {
|
|
166
|
+
return res.status(400).json({ success: false, error: 'Current password required to remove PIN' });
|
|
167
|
+
}
|
|
168
|
+
const hash = hashPassword(password, config.salt);
|
|
169
|
+
const match = crypto_1.default.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(config.hash, 'hex'));
|
|
170
|
+
if (!match) {
|
|
171
|
+
return res.status(401).json({ success: false, error: 'Incorrect password' });
|
|
172
|
+
}
|
|
173
|
+
const { pinHash: _ph, pinSalt: _ps, ...rest } = config;
|
|
174
|
+
saveAuthConfig(rest);
|
|
175
|
+
res.json({ success: true });
|
|
176
|
+
});
|
|
177
|
+
exports.default = router;
|