neoagent 2.4.1-beta.45 → 2.4.1-beta.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +12 -0
- package/package.json +1 -1
- package/server/admin/access.js +198 -0
- package/server/admin/admin.js +2 -2
- package/server/admin/analytics.js +128 -0
- package/server/admin/index.html +484 -1
- package/server/admin/login.html +171 -45
- package/server/admin/sql.js +134 -0
- package/server/admin/users.js +147 -0
- package/server/db/database.js +19 -0
- package/server/http/static.js +20 -6
- package/server/middleware/adminAuth.js +37 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +4 -4
- package/server/routes/admin.js +316 -5
- package/server/services/account/admin_two_factor.js +132 -0
|
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"c416acfeb8126e097f758c664aaa3da929e27d
|
|
|
37
37
|
|
|
38
38
|
_flutter.loader.load({
|
|
39
39
|
serviceWorkerSettings: {
|
|
40
|
-
serviceWorkerVersion: "
|
|
40
|
+
serviceWorkerVersion: "111346624" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
|
|
41
41
|
}
|
|
42
42
|
});
|
|
@@ -133824,7 +133824,7 @@ r===$&&A.b()
|
|
|
133824
133824
|
o.push(A.j4(p,A.j8(!1,new A.a3(B.up,A.dl(new A.cv(B.hr,new A.a7p(r,p),p),p,p),p),!1,B.I,!0),p,p,0,0,0,p))}r=!1
|
|
133825
133825
|
if(!s.ay)if(!s.ch){r=s.e
|
|
133826
133826
|
r===$&&A.b()
|
|
133827
|
-
r=B.b.t("
|
|
133827
|
+
r=B.b.t("mq73wuaf-558fe68").length!==0&&r.b}if(r){r=s.d
|
|
133828
133828
|
r===$&&A.b()
|
|
133829
133829
|
r=r.au&&!r.aQ?84:0
|
|
133830
133830
|
q=s.e
|
|
@@ -139266,7 +139266,7 @@ $S:0}
|
|
|
139266
139266
|
A.ZM.prototype={}
|
|
139267
139267
|
A.Sw.prototype={
|
|
139268
139268
|
n9(a){var s=this
|
|
139269
|
-
if(B.b.t("
|
|
139269
|
+
if(B.b.t("mq73wuaf-558fe68").length===0||s.a!=null)return
|
|
139270
139270
|
s.AN()
|
|
139271
139271
|
s.a=A.qh(B.Rb,new A.bas(s))},
|
|
139272
139272
|
AN(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f
|
|
@@ -139284,7 +139284,7 @@ if(!t.f.b(k)){s=1
|
|
|
139284
139284
|
break}i=J.a_(k,"buildId")
|
|
139285
139285
|
h=i==null?null:B.b.t(J.p(i))
|
|
139286
139286
|
j=h==null?"":h
|
|
139287
|
-
if(J.bf(j)===0||J.d(j,"
|
|
139287
|
+
if(J.bf(j)===0||J.d(j,"mq73wuaf-558fe68")){s=1
|
|
139288
139288
|
break}n.b=!0
|
|
139289
139289
|
n.E()
|
|
139290
139290
|
p=2
|
|
@@ -139301,7 +139301,7 @@ case 2:return A.i(o.at(-1),r)}})
|
|
|
139301
139301
|
return A.k($async$AN,r)},
|
|
139302
139302
|
vw(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f,e,d,c,b,a,a0,a1
|
|
139303
139303
|
var $async$vw=A.h(function(a2,a3){if(a2===1){o.push(a3)
|
|
139304
|
-
s=p}for(;;)switch(s){case 0:if(B.b.t("
|
|
139304
|
+
s=p}for(;;)switch(s){case 0:if(B.b.t("mq73wuaf-558fe68").length===0||n.c){s=1
|
|
139305
139305
|
break}n.c=!0
|
|
139306
139306
|
n.E()
|
|
139307
139307
|
p=4
|
package/server/routes/admin.js
CHANGED
|
@@ -22,6 +22,21 @@ const rateLimit = require('express-rate-limit');
|
|
|
22
22
|
const router = express.Router();
|
|
23
23
|
const ADMIN_DIR = path.join(__dirname, '..', 'admin');
|
|
24
24
|
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
|
|
27
|
+
// Admin sessions last 30 days and roll on every request.
|
|
28
|
+
const ADMIN_SESSION_TTL = 30 * 24 * 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
// Rolling refresh: touch the session on every authenticated admin request
|
|
31
|
+
// so the 30-day window slides forward from the last activity.
|
|
32
|
+
router.use((req, res, next) => {
|
|
33
|
+
if (req.session?.isAdmin) {
|
|
34
|
+
req.session.cookie.maxAge = ADMIN_SESSION_TTL;
|
|
35
|
+
req.session.touch();
|
|
36
|
+
}
|
|
37
|
+
next();
|
|
38
|
+
});
|
|
39
|
+
|
|
25
40
|
const loginLimiter = rateLimit({
|
|
26
41
|
windowMs: 15 * 60 * 1000,
|
|
27
42
|
max: 20,
|
|
@@ -46,21 +61,55 @@ router.get('/login', (req, res) => {
|
|
|
46
61
|
res.sendFile(path.join(ADMIN_DIR, 'login.html'));
|
|
47
62
|
});
|
|
48
63
|
|
|
49
|
-
router.post('/api/login', loginLimiter, express.json(), (req, res) => {
|
|
64
|
+
router.post('/api/login', loginLimiter, express.json(), async (req, res) => {
|
|
50
65
|
const { username, password } = req.body || {};
|
|
51
66
|
const expectedUsername = process.env.ADMIN_USERNAME || 'admin';
|
|
52
67
|
const expectedPassword = process.env.ADMIN_PASSWORD || '';
|
|
53
68
|
if (!expectedPassword) {
|
|
54
69
|
return res.status(503).json({ error: 'Admin credentials not configured. Run `neoagent setup`.' });
|
|
55
70
|
}
|
|
56
|
-
if (username
|
|
71
|
+
if (username !== expectedUsername || password !== expectedPassword) {
|
|
72
|
+
return res.status(401).json({ error: 'Invalid credentials' });
|
|
73
|
+
}
|
|
74
|
+
// Check whether 2FA is enabled
|
|
75
|
+
const adminTwoFactor = require('../services/account/admin_two_factor');
|
|
76
|
+
const tfStatus = adminTwoFactor.getStatus();
|
|
77
|
+
if (tfStatus.enabled) {
|
|
78
|
+
// Park the session in a "password OK, waiting for TOTP" state
|
|
79
|
+
req.session.adminPendingTwoFactor = true;
|
|
80
|
+
delete req.session.isAdmin;
|
|
81
|
+
return req.session.save((err) => {
|
|
82
|
+
if (err) return res.status(500).json({ error: 'Session error' });
|
|
83
|
+
res.json({ ok: false, requiresTwoFactor: true });
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
req.session.isAdmin = true;
|
|
87
|
+
req.session.cookie.maxAge = ADMIN_SESSION_TTL;
|
|
88
|
+
req.session.save((err) => {
|
|
89
|
+
if (err) return res.status(500).json({ error: 'Session error' });
|
|
90
|
+
res.json({ ok: true });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Second-factor verification (called after a successful password login when 2FA is on)
|
|
95
|
+
router.post('/api/2fa/verify', loginLimiter, express.json(), async (req, res) => {
|
|
96
|
+
if (!req.session?.adminPendingTwoFactor) {
|
|
97
|
+
return res.status(400).json({ error: 'No pending 2FA verification' });
|
|
98
|
+
}
|
|
99
|
+
const { code } = req.body || {};
|
|
100
|
+
try {
|
|
101
|
+
const adminTwoFactor = require('../services/account/admin_two_factor');
|
|
102
|
+
const valid = await adminTwoFactor.verifyCode(code);
|
|
103
|
+
if (!valid) return res.status(401).json({ error: 'Invalid code — try again' });
|
|
104
|
+
req.session.adminPendingTwoFactor = false;
|
|
57
105
|
req.session.isAdmin = true;
|
|
106
|
+
req.session.cookie.maxAge = ADMIN_SESSION_TTL;
|
|
58
107
|
req.session.save((err) => {
|
|
59
108
|
if (err) return res.status(500).json({ error: 'Session error' });
|
|
60
109
|
res.json({ ok: true });
|
|
61
110
|
});
|
|
62
|
-
}
|
|
63
|
-
res.status(
|
|
111
|
+
} catch (err) {
|
|
112
|
+
res.status(500).json({ error: err.message });
|
|
64
113
|
}
|
|
65
114
|
});
|
|
66
115
|
|
|
@@ -262,6 +311,268 @@ router.put('/api/update/channel', requireAdminAuth, (req, res) => {
|
|
|
262
311
|
});
|
|
263
312
|
});
|
|
264
313
|
|
|
314
|
+
const settingsLimiter = rateLimit({
|
|
315
|
+
windowMs: 15 * 60 * 1000,
|
|
316
|
+
max: 20,
|
|
317
|
+
standardHeaders: true,
|
|
318
|
+
legacyHeaders: false,
|
|
319
|
+
message: { error: 'Too many settings changes, slow down' },
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const sqlLimiter = rateLimit({
|
|
323
|
+
windowMs: 15 * 60 * 1000,
|
|
324
|
+
max: 60,
|
|
325
|
+
standardHeaders: true,
|
|
326
|
+
legacyHeaders: false,
|
|
327
|
+
message: { error: 'Too many SQL queries, slow down' },
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// --- Access settings (signup toggle + API key) ---
|
|
331
|
+
|
|
332
|
+
router.get('/api/settings', requireAdminAuth, (req, res) => {
|
|
333
|
+
const apiKey = process.env.ADMIN_API_KEY || '';
|
|
334
|
+
const hint = apiKey.length > 8
|
|
335
|
+
? `${apiKey.slice(0, 4)}${'•'.repeat(4)}${apiKey.slice(-4)}`
|
|
336
|
+
: apiKey ? '•'.repeat(apiKey.length) : '';
|
|
337
|
+
const adminTwoFactor = require('../services/account/admin_two_factor');
|
|
338
|
+
const tfStatus = adminTwoFactor.getStatus();
|
|
339
|
+
res.json({
|
|
340
|
+
signupEnabled: process.env.NEOAGENT_ALLOW_SIGNUP !== 'false',
|
|
341
|
+
apiKeyConfigured: Boolean(apiKey),
|
|
342
|
+
apiKeyHint: hint,
|
|
343
|
+
twoFactor: tfStatus,
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// --- 2FA management ---
|
|
348
|
+
|
|
349
|
+
router.get('/api/settings/2fa', requireAdminAuth, (req, res) => {
|
|
350
|
+
const adminTwoFactor = require('../services/account/admin_two_factor');
|
|
351
|
+
res.json(adminTwoFactor.getStatus());
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
router.post('/api/settings/2fa/setup', requireAdminAuth, settingsLimiter, async (req, res) => {
|
|
355
|
+
try {
|
|
356
|
+
const adminTwoFactor = require('../services/account/admin_two_factor');
|
|
357
|
+
const { otpauthUrl, manualKey } = adminTwoFactor.beginSetup();
|
|
358
|
+
const qrcode = require('qrcode');
|
|
359
|
+
const qrDataUrl = await qrcode.toDataURL(otpauthUrl, { width: 200, margin: 2 });
|
|
360
|
+
res.json({ qrDataUrl, manualKey });
|
|
361
|
+
} catch (err) {
|
|
362
|
+
res.status(err.statusCode || 500).json({ error: err.message });
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
router.post('/api/settings/2fa/enable', requireAdminAuth, settingsLimiter, express.json(), async (req, res) => {
|
|
367
|
+
try {
|
|
368
|
+
const adminTwoFactor = require('../services/account/admin_two_factor');
|
|
369
|
+
const { recoveryCodes } = await adminTwoFactor.enable(req.body?.code);
|
|
370
|
+
res.json({ ok: true, recoveryCodes });
|
|
371
|
+
} catch (err) {
|
|
372
|
+
res.status(err.statusCode || 500).json({ error: err.message });
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
router.delete('/api/settings/2fa', requireAdminAuth, settingsLimiter, express.json(), async (req, res) => {
|
|
377
|
+
try {
|
|
378
|
+
const adminTwoFactor = require('../services/account/admin_two_factor');
|
|
379
|
+
await adminTwoFactor.disable(req.body?.code);
|
|
380
|
+
res.json({ ok: true });
|
|
381
|
+
} catch (err) {
|
|
382
|
+
res.status(err.statusCode || 500).json({ error: err.message });
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
router.post('/api/settings/2fa/recovery-codes', requireAdminAuth, settingsLimiter, express.json(), async (req, res) => {
|
|
387
|
+
try {
|
|
388
|
+
const adminTwoFactor = require('../services/account/admin_two_factor');
|
|
389
|
+
const { recoveryCodes } = await adminTwoFactor.regenerateCodes(req.body?.code);
|
|
390
|
+
res.json({ ok: true, recoveryCodes });
|
|
391
|
+
} catch (err) {
|
|
392
|
+
res.status(err.statusCode || 500).json({ error: err.message });
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
router.put('/api/settings/signup', requireAdminAuth, settingsLimiter, express.json(), (req, res) => {
|
|
397
|
+
const enabled = req.body?.enabled !== false; // default true
|
|
398
|
+
const value = enabled ? 'true' : 'false';
|
|
399
|
+
upsertEnvValue(ENV_FILE, 'NEOAGENT_ALLOW_SIGNUP', value);
|
|
400
|
+
process.env.NEOAGENT_ALLOW_SIGNUP = value;
|
|
401
|
+
res.json({ ok: true, signupEnabled: enabled });
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
router.post('/api/settings/apikey/rotate', requireAdminAuth, settingsLimiter, (req, res) => {
|
|
405
|
+
const newKey = require('crypto').randomBytes(32).toString('hex');
|
|
406
|
+
upsertEnvValue(ENV_FILE, 'ADMIN_API_KEY', newKey);
|
|
407
|
+
process.env.ADMIN_API_KEY = newKey;
|
|
408
|
+
// Return the key once — it will not be shown again
|
|
409
|
+
res.json({ ok: true, apiKey: newKey });
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
router.delete('/api/settings/apikey', requireAdminAuth, settingsLimiter, (req, res) => {
|
|
413
|
+
upsertEnvValue(ENV_FILE, 'ADMIN_API_KEY', '');
|
|
414
|
+
delete process.env.ADMIN_API_KEY;
|
|
415
|
+
res.json({ ok: true });
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// --- Analytics ---
|
|
419
|
+
|
|
420
|
+
router.get('/api/analytics', requireAdminAuth, (req, res) => {
|
|
421
|
+
const db = require('../db/database');
|
|
422
|
+
const now = new Date().toISOString();
|
|
423
|
+
const dayAgo = new Date(Date.now() - 86_400_000).toISOString();
|
|
424
|
+
const weekAgo = new Date(Date.now() - 7 * 86_400_000).toISOString();
|
|
425
|
+
try {
|
|
426
|
+
const stats = {
|
|
427
|
+
totalUsers: db.prepare('SELECT COUNT(*) AS n FROM users').get().n,
|
|
428
|
+
activeToday: db.prepare('SELECT COUNT(*) AS n FROM users WHERE last_login > ?').get(dayAgo).n,
|
|
429
|
+
newThisWeek: db.prepare('SELECT COUNT(*) AS n FROM users WHERE created_at > ?').get(weekAgo).n,
|
|
430
|
+
totalRuns: db.prepare('SELECT COUNT(*) AS n FROM agent_runs').get().n,
|
|
431
|
+
runsToday: db.prepare('SELECT COUNT(*) AS n FROM agent_runs WHERE created_at > ?').get(dayAgo).n,
|
|
432
|
+
totalTokens: db.prepare('SELECT COALESCE(SUM(total_tokens),0) AS n FROM agent_runs').get().n,
|
|
433
|
+
activeSessions:db.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE revoked_at IS NULL AND expires_at > ?').get(now).n,
|
|
434
|
+
totalStorage: db.prepare('SELECT COALESCE(SUM(byte_size),0) AS n FROM artifacts').get().n,
|
|
435
|
+
};
|
|
436
|
+
const topUsers = db.prepare(`
|
|
437
|
+
SELECT u.id, u.username, u.display_name,
|
|
438
|
+
COALESCE(r.runs, 0) AS runs,
|
|
439
|
+
COALESCE(r.tokens, 0) AS tokens,
|
|
440
|
+
COALESCE(a.storage, 0) AS storage
|
|
441
|
+
FROM users u
|
|
442
|
+
LEFT JOIN (
|
|
443
|
+
SELECT user_id,
|
|
444
|
+
COUNT(*) AS runs,
|
|
445
|
+
COALESCE(SUM(total_tokens),0) AS tokens
|
|
446
|
+
FROM agent_runs GROUP BY user_id
|
|
447
|
+
) r ON r.user_id = u.id
|
|
448
|
+
LEFT JOIN (
|
|
449
|
+
SELECT user_id, COALESCE(SUM(byte_size),0) AS storage
|
|
450
|
+
FROM artifacts GROUP BY user_id
|
|
451
|
+
) a ON a.user_id = u.id
|
|
452
|
+
ORDER BY runs DESC LIMIT 8
|
|
453
|
+
`).all();
|
|
454
|
+
const recentRuns = db.prepare(`
|
|
455
|
+
SELECT r.id, u.username, r.title, r.status, r.total_tokens,
|
|
456
|
+
r.created_at, r.completed_at
|
|
457
|
+
FROM agent_runs r
|
|
458
|
+
JOIN users u ON u.id = r.user_id
|
|
459
|
+
ORDER BY r.created_at DESC LIMIT 20
|
|
460
|
+
`).all();
|
|
461
|
+
res.json({ stats, topUsers, recentRuns });
|
|
462
|
+
} catch (err) {
|
|
463
|
+
res.status(500).json({ error: String(err.message || err) });
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// --- User management ---
|
|
468
|
+
|
|
469
|
+
router.get('/api/users', requireAdminAuth, (req, res) => {
|
|
470
|
+
const db = require('../db/database');
|
|
471
|
+
const q = req.query.q ? `%${req.query.q}%` : null;
|
|
472
|
+
try {
|
|
473
|
+
const sql = `
|
|
474
|
+
SELECT u.id, u.username, u.display_name, u.email, u.email_verified_at,
|
|
475
|
+
u.created_at, u.last_login,
|
|
476
|
+
COALESCE(r.run_count, 0) AS run_count,
|
|
477
|
+
COALESCE(a.storage_bytes,0) AS storage_bytes
|
|
478
|
+
FROM users u
|
|
479
|
+
LEFT JOIN (
|
|
480
|
+
SELECT user_id, COUNT(*) AS run_count
|
|
481
|
+
FROM agent_runs GROUP BY user_id
|
|
482
|
+
) r ON r.user_id = u.id
|
|
483
|
+
LEFT JOIN (
|
|
484
|
+
SELECT user_id, COALESCE(SUM(byte_size),0) AS storage_bytes
|
|
485
|
+
FROM artifacts GROUP BY user_id
|
|
486
|
+
) a ON a.user_id = u.id
|
|
487
|
+
${q ? 'WHERE u.username LIKE ? OR u.email LIKE ?' : ''}
|
|
488
|
+
ORDER BY u.created_at DESC LIMIT 200`;
|
|
489
|
+
const users = q ? db.prepare(sql).all(q, q) : db.prepare(sql).all();
|
|
490
|
+
res.json({ users });
|
|
491
|
+
} catch (err) {
|
|
492
|
+
res.status(500).json({ error: String(err.message || err) });
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
router.delete('/api/users/:id', requireAdminAuth, (req, res) => {
|
|
497
|
+
const db = require('../db/database');
|
|
498
|
+
const { DATA_DIR } = require('../../runtime/paths');
|
|
499
|
+
const { id } = req.params;
|
|
500
|
+
if (!id) return res.status(400).json({ error: 'Missing user id' });
|
|
501
|
+
try {
|
|
502
|
+
// Collect artifact paths before deletion
|
|
503
|
+
const artifacts = db.prepare('SELECT storage_path FROM artifacts WHERE user_id = ?').all(id);
|
|
504
|
+
|
|
505
|
+
const erase = db.transaction((uid) => {
|
|
506
|
+
db.prepare('DELETE FROM conversation_messages WHERE conversation_id IN (SELECT id FROM conversations WHERE user_id = ?)').run(uid);
|
|
507
|
+
db.prepare('DELETE FROM conversations WHERE user_id = ?').run(uid);
|
|
508
|
+
db.prepare('DELETE FROM agent_steps WHERE run_id IN (SELECT id FROM agent_runs WHERE user_id = ?)').run(uid);
|
|
509
|
+
db.prepare('DELETE FROM agent_runs WHERE user_id = ?').run(uid);
|
|
510
|
+
db.prepare('DELETE FROM messages WHERE user_id = ?').run(uid);
|
|
511
|
+
db.prepare('DELETE FROM memories WHERE user_id = ?').run(uid);
|
|
512
|
+
db.prepare('DELETE FROM integration_connections WHERE user_id = ?').run(uid);
|
|
513
|
+
db.prepare('DELETE FROM platform_connections WHERE user_id = ?').run(uid);
|
|
514
|
+
db.prepare('DELETE FROM mcp_servers WHERE user_id = ?').run(uid);
|
|
515
|
+
db.prepare('DELETE FROM user_settings WHERE user_id = ?').run(uid);
|
|
516
|
+
db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(uid);
|
|
517
|
+
db.prepare('DELETE FROM desktop_companion_devices WHERE user_id = ?').run(uid);
|
|
518
|
+
db.prepare('DELETE FROM scheduled_tasks WHERE user_id = ?').run(uid);
|
|
519
|
+
db.prepare('DELETE FROM recording_sessions WHERE user_id = ?').run(uid);
|
|
520
|
+
db.prepare('DELETE FROM screen_history WHERE user_id = ?').run(uid);
|
|
521
|
+
db.prepare('DELETE FROM agents WHERE user_id = ?').run(uid);
|
|
522
|
+
db.prepare('DELETE FROM artifacts WHERE user_id = ?').run(uid);
|
|
523
|
+
db.prepare('DELETE FROM users WHERE id = ?').run(uid);
|
|
524
|
+
});
|
|
525
|
+
erase(id);
|
|
526
|
+
|
|
527
|
+
// Clean up artifact files on disk
|
|
528
|
+
for (const artifact of artifacts) {
|
|
529
|
+
try {
|
|
530
|
+
const abs = path.isAbsolute(artifact.storage_path)
|
|
531
|
+
? artifact.storage_path
|
|
532
|
+
: path.join(DATA_DIR, artifact.storage_path);
|
|
533
|
+
fs.rmSync(abs, { force: true });
|
|
534
|
+
} catch {}
|
|
535
|
+
}
|
|
536
|
+
try { fs.rmSync(path.join(DATA_DIR, 'artifacts', id), { recursive: true, force: true }); } catch {}
|
|
537
|
+
|
|
538
|
+
res.json({ ok: true });
|
|
539
|
+
} catch (err) {
|
|
540
|
+
res.status(500).json({ error: String(err.message || err) });
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
router.delete('/api/users/:id/sessions', requireAdminAuth, (req, res) => {
|
|
545
|
+
const db = require('../db/database');
|
|
546
|
+
const { id } = req.params;
|
|
547
|
+
try {
|
|
548
|
+
db.prepare('UPDATE user_sessions SET revoked_at = ? WHERE user_id = ?').run(new Date().toISOString(), id);
|
|
549
|
+
res.json({ ok: true });
|
|
550
|
+
} catch (err) {
|
|
551
|
+
res.status(500).json({ error: String(err.message || err) });
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// --- SQL editor (read-only SELECT only) ---
|
|
556
|
+
|
|
557
|
+
const SQL_BLOCKED = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|ATTACH|DETACH|TRUNCATE|VACUUM|REINDEX|REPLACE|UPSERT|PRAGMA)\b/i;
|
|
558
|
+
|
|
559
|
+
router.post('/api/sql', requireAdminAuth, sqlLimiter, express.json(), (req, res) => {
|
|
560
|
+
const { query } = req.body || {};
|
|
561
|
+
if (!query || typeof query !== 'string') return res.status(400).json({ error: 'No query provided' });
|
|
562
|
+
const trimmed = query.trim();
|
|
563
|
+
if (!/^(SELECT|WITH)\b/i.test(trimmed)) return res.status(400).json({ error: 'Only SELECT (or WITH …) queries are allowed' });
|
|
564
|
+
if (SQL_BLOCKED.test(trimmed)) return res.status(400).json({ error: 'Query contains a blocked SQL keyword' });
|
|
565
|
+
try {
|
|
566
|
+
const db = require('../db/database');
|
|
567
|
+
const rows = db.prepare(trimmed).all();
|
|
568
|
+
const limited = rows.slice(0, 500);
|
|
569
|
+
const columns = limited.length ? Object.keys(limited[0]) : [];
|
|
570
|
+
res.json({ rows: limited, columns, truncated: rows.length > 500, total: rows.length });
|
|
571
|
+
} catch (err) {
|
|
572
|
+
res.status(400).json({ error: String(err.message || err) });
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
|
|
265
576
|
// --- Providers ---
|
|
266
577
|
|
|
267
578
|
const PROVIDERS = [
|
|
@@ -300,7 +611,7 @@ router.put('/api/providers', requireAdminAuth, express.json(), (req, res) => {
|
|
|
300
611
|
if (!ALLOWED_PROVIDER_KEYS.has(key)) {
|
|
301
612
|
return res.status(400).json({ error: 'Unknown provider key' });
|
|
302
613
|
}
|
|
303
|
-
const trimmed = String(value || '').trim();
|
|
614
|
+
const trimmed = String(value || '').trim().replace(/[\r\n]/g, '');
|
|
304
615
|
upsertEnvValue(ENV_FILE, key, trimmed);
|
|
305
616
|
if (trimmed) {
|
|
306
617
|
process.env[key] = trimmed;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const bcrypt = require('bcrypt');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const { generateSecret, generateURI, verifySync } = require('otplib');
|
|
6
|
+
const db = require('../../db/database');
|
|
7
|
+
const { encryptValue, decryptValue } = require('../integrations/secrets');
|
|
8
|
+
|
|
9
|
+
const TOTP_OPTS = { strategy: 'totp', digits: 6, period: 30, epochTolerance: 30 };
|
|
10
|
+
const RECOVERY_ALPHA = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
11
|
+
|
|
12
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function getRow() {
|
|
15
|
+
return db.prepare('SELECT * FROM admin_two_factor WHERE id = 1').get() || null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeCode(v) {
|
|
19
|
+
return String(v || '').trim().replace(/\s+/g, '').toUpperCase();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function verifyTotpCode(secret, code) {
|
|
23
|
+
const token = String(code || '').trim().replace(/\s+/g, '');
|
|
24
|
+
if (!/^\d{6}$/.test(token)) return false;
|
|
25
|
+
return verifySync({ ...TOTP_OPTS, secret, token })?.valid === true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeRecoveryCode() {
|
|
29
|
+
let raw = '';
|
|
30
|
+
while (raw.length < 10) raw += RECOVERY_ALPHA[crypto.randomInt(RECOVERY_ALPHA.length)];
|
|
31
|
+
return `${raw.slice(0, 5)}-${raw.slice(5)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
function getStatus() {
|
|
37
|
+
const row = getRow();
|
|
38
|
+
const codesLeft = db.prepare('SELECT COUNT(*) AS n FROM admin_recovery_codes WHERE used_at IS NULL').get().n;
|
|
39
|
+
return {
|
|
40
|
+
enabled: row?.enabled === 1,
|
|
41
|
+
pending: Boolean(row?.pending_secret),
|
|
42
|
+
enabledAt: row?.enabled_at || null,
|
|
43
|
+
recoveryCodesRemaining: codesLeft,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function beginSetup() {
|
|
48
|
+
if (!String(process.env.SESSION_SECRET || '').trim()) {
|
|
49
|
+
throw Object.assign(new Error('SESSION_SECRET must be configured before enabling 2FA'), { statusCode: 500 });
|
|
50
|
+
}
|
|
51
|
+
const secret = generateSecret();
|
|
52
|
+
const otpauthUrl = generateURI({ ...TOTP_OPTS, issuer: 'NeoAgent Admin', label: 'admin', secret });
|
|
53
|
+
db.prepare(`
|
|
54
|
+
INSERT INTO admin_two_factor (id, pending_secret, enabled, created_at, updated_at)
|
|
55
|
+
VALUES (1, ?, COALESCE((SELECT enabled FROM admin_two_factor WHERE id = 1), 0), datetime('now'), datetime('now'))
|
|
56
|
+
ON CONFLICT(id) DO UPDATE SET pending_secret = excluded.pending_secret, updated_at = datetime('now')
|
|
57
|
+
`).run(encryptValue(secret));
|
|
58
|
+
return { otpauthUrl, manualKey: secret };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function enable(code) {
|
|
62
|
+
const row = getRow();
|
|
63
|
+
if (!row?.pending_secret) throw Object.assign(new Error('No 2FA setup pending'), { statusCode: 400 });
|
|
64
|
+
const secret = decryptValue(row.pending_secret);
|
|
65
|
+
if (!verifyTotpCode(secret, code)) throw Object.assign(new Error('Invalid code — try again'), { statusCode: 401 });
|
|
66
|
+
|
|
67
|
+
const codes = Array.from({ length: 10 }, makeRecoveryCode);
|
|
68
|
+
const hashes = await Promise.all(codes.map((c) => bcrypt.hash(normalizeCode(c).replace(/-/g, ''), 12)));
|
|
69
|
+
|
|
70
|
+
db.transaction(() => {
|
|
71
|
+
db.prepare('DELETE FROM admin_recovery_codes').run();
|
|
72
|
+
const ins = db.prepare("INSERT INTO admin_recovery_codes (code_hash, created_at) VALUES (?, datetime('now'))");
|
|
73
|
+
for (const h of hashes) ins.run(h);
|
|
74
|
+
db.prepare(`
|
|
75
|
+
UPDATE admin_two_factor
|
|
76
|
+
SET secret = pending_secret, pending_secret = NULL, enabled = 1,
|
|
77
|
+
enabled_at = datetime('now'), updated_at = datetime('now')
|
|
78
|
+
WHERE id = 1
|
|
79
|
+
`).run();
|
|
80
|
+
})();
|
|
81
|
+
|
|
82
|
+
return { recoveryCodes: codes };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Verifies a TOTP code or recovery code.
|
|
87
|
+
* Returns true if valid (or if 2FA is not enabled).
|
|
88
|
+
*/
|
|
89
|
+
async function verifyCode(code) {
|
|
90
|
+
const row = getRow();
|
|
91
|
+
if (!row?.enabled || !row.secret) return true; // 2FA not configured
|
|
92
|
+
|
|
93
|
+
const secret = decryptValue(row.secret);
|
|
94
|
+
if (verifyTotpCode(secret, code)) return true;
|
|
95
|
+
|
|
96
|
+
// Try recovery code
|
|
97
|
+
const normalized = normalizeCode(String(code || '')).replace(/-/g, '');
|
|
98
|
+
if (normalized.length < 6) return false;
|
|
99
|
+
const rows = db.prepare('SELECT id, code_hash FROM admin_recovery_codes WHERE used_at IS NULL ORDER BY id').all();
|
|
100
|
+
for (const r of rows) {
|
|
101
|
+
if (await bcrypt.compare(normalized, r.code_hash)) {
|
|
102
|
+
db.prepare("UPDATE admin_recovery_codes SET used_at = datetime('now') WHERE id = ?").run(r.id);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function disable(code) {
|
|
110
|
+
const row = getRow();
|
|
111
|
+
if (!row?.enabled) return;
|
|
112
|
+
if (!await verifyCode(code)) throw Object.assign(new Error('Invalid 2FA code'), { statusCode: 401 });
|
|
113
|
+
db.transaction(() => {
|
|
114
|
+
db.prepare(`UPDATE admin_two_factor SET enabled = 0, secret = NULL, pending_secret = NULL,
|
|
115
|
+
enabled_at = NULL, updated_at = datetime('now') WHERE id = 1`).run();
|
|
116
|
+
db.prepare('DELETE FROM admin_recovery_codes').run();
|
|
117
|
+
})();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function regenerateCodes(code) {
|
|
121
|
+
if (!await verifyCode(code)) throw Object.assign(new Error('Invalid 2FA code'), { statusCode: 401 });
|
|
122
|
+
const codes = Array.from({ length: 10 }, makeRecoveryCode);
|
|
123
|
+
const hashes = await Promise.all(codes.map((c) => bcrypt.hash(normalizeCode(c).replace(/-/g, ''), 12)));
|
|
124
|
+
db.transaction(() => {
|
|
125
|
+
db.prepare('DELETE FROM admin_recovery_codes').run();
|
|
126
|
+
const ins = db.prepare("INSERT INTO admin_recovery_codes (code_hash, created_at) VALUES (?, datetime('now'))");
|
|
127
|
+
for (const h of hashes) ins.run(h);
|
|
128
|
+
})();
|
|
129
|
+
return { recoveryCodes: codes };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { getStatus, beginSetup, enable, verifyCode, disable, regenerateCodes };
|