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.
Files changed (43) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +321 -295
  3. package/bin/ezpm2gui.js +10 -10
  4. package/bin/ezpm2gui.ts +51 -51
  5. package/bin/generate-ecosystem.js +36 -36
  6. package/bin/generate-ecosystem.ts +56 -56
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.js +2 -2
  9. package/dist/server/config/project-configs.json +236 -0
  10. package/dist/server/index.js +214 -25
  11. package/dist/server/routes/deployApplication.js +6 -5
  12. package/dist/server/routes/pageAuth.d.ts +3 -0
  13. package/dist/server/routes/pageAuth.js +177 -0
  14. package/dist/server/routes/remoteConnections.js +260 -0
  15. package/dist/server/routes/updates.d.ts +3 -0
  16. package/dist/server/routes/updates.js +135 -0
  17. package/dist/server/utils/remote-connection.d.ts +18 -0
  18. package/dist/server/utils/remote-connection.js +216 -9
  19. package/package.json +73 -71
  20. package/scripts/postinstall.js +36 -36
  21. package/src/client/build/asset-manifest.json +6 -6
  22. package/src/client/build/favicon.ico +2 -2
  23. package/src/client/build/index.html +1 -1
  24. package/src/client/build/logo192.svg +7 -7
  25. package/src/client/build/logo512.svg +7 -7
  26. package/src/client/build/manifest.json +24 -24
  27. package/src/client/build/static/css/main.775772ee.css +5 -0
  28. package/src/client/build/static/css/main.775772ee.css.map +1 -0
  29. package/src/client/build/static/js/main.cbcb09c9.js +3 -0
  30. package/src/client/build/static/js/main.cbcb09c9.js.map +1 -0
  31. package/dist/server/config/cron-jobs.json +0 -1
  32. package/dist/server/config/remote-connections.json +0 -3
  33. package/dist/server/daemon/ezpm2gui.err.log +0 -414
  34. package/dist/server/daemon/ezpm2gui.exe +0 -0
  35. package/dist/server/daemon/ezpm2gui.exe.config +0 -6
  36. package/dist/server/daemon/ezpm2gui.out.log +0 -289
  37. package/dist/server/daemon/ezpm2gui.wrapper.log +0 -172
  38. package/dist/server/daemon/ezpm2gui.xml +0 -32
  39. package/src/client/build/static/css/main.c506cba5.css +0 -5
  40. package/src/client/build/static/css/main.c506cba5.css.map +0 -1
  41. package/src/client/build/static/js/main.5278cddd.js +0 -3
  42. package/src/client/build/static/js/main.5278cddd.js.map +0 -1
  43. /package/src/client/build/static/js/{main.5278cddd.js.LICENSE.txt → main.cbcb09c9.js.LICENSE.txt} +0 -0
@@ -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
- // Get process logs
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 processDesc = await (0, pm2_connection_1.executePM2Command)((callback) => {
137
- pm2_1.default.describe(id, callback);
138
- });
139
- if (!processDesc || processDesc.length === 0) {
140
- res.status(404).json({ error: 'Process not found' });
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 logPath = (_b = (_a = processDesc[0]) === null || _a === void 0 ? void 0 : _a.pm2_env) === null || _b === void 0 ? void 0 : _b[`pm_${logType}_log_path`];
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: `Log file for ${logType} not found` });
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
- let logContent = '';
150
- if (fs.existsSync(logPath)) {
151
- const stats = fs.statSync(logPath);
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 logs = logContent.split('\n').filter((line) => line.trim() !== '');
162
- res.json({ logs });
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 reading log file: ${err}`);
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 || 3001;
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,3 @@
1
+ import { Router } from 'express';
2
+ declare const router: Router;
3
+ export default router;
@@ -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;