agentopia 1.0.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/.claude/settings.local.json +28 -0
- package/dist/app.d.ts +10 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +121 -0
- package/dist/app.js.map +1 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +19 -0
- package/dist/config.js.map +1 -0
- package/dist/db/database.d.ts +5 -0
- package/dist/db/database.d.ts.map +1 -0
- package/dist/db/database.js +39 -0
- package/dist/db/database.js.map +1 -0
- package/dist/db/schema.d.ts +3 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +621 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +4 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +9 -0
- package/dist/logger.js.map +1 -0
- package/dist/middleware/auth.d.ts +13 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +733 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/routes/agents.d.ts +3 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +1058 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/issues.d.ts +4 -0
- package/dist/routes/issues.d.ts.map +1 -0
- package/dist/routes/issues.js +946 -0
- package/dist/routes/issues.js.map +1 -0
- package/dist/routes/knowledge.d.ts +3 -0
- package/dist/routes/knowledge.d.ts.map +1 -0
- package/dist/routes/knowledge.js +117 -0
- package/dist/routes/knowledge.js.map +1 -0
- package/dist/routes/memories.d.ts +3 -0
- package/dist/routes/memories.d.ts.map +1 -0
- package/dist/routes/memories.js +115 -0
- package/dist/routes/memories.js.map +1 -0
- package/dist/routes/messages.d.ts +3 -0
- package/dist/routes/messages.d.ts.map +1 -0
- package/dist/routes/messages.js +130 -0
- package/dist/routes/messages.js.map +1 -0
- package/dist/routes/projects.d.ts +3 -0
- package/dist/routes/projects.d.ts.map +1 -0
- package/dist/routes/projects.js +754 -0
- package/dist/routes/projects.js.map +1 -0
- package/dist/routes/templates.d.ts +3 -0
- package/dist/routes/templates.d.ts.map +1 -0
- package/dist/routes/templates.js +117 -0
- package/dist/routes/templates.js.map +1 -0
- package/dist/routes/ui.d.ts +3 -0
- package/dist/routes/ui.d.ts.map +1 -0
- package/dist/routes/ui.js +38 -0
- package/dist/routes/ui.js.map +1 -0
- package/dist/services/agent-hierarchy.d.ts +14 -0
- package/dist/services/agent-hierarchy.d.ts.map +1 -0
- package/dist/services/agent-hierarchy.js +58 -0
- package/dist/services/agent-hierarchy.js.map +1 -0
- package/dist/services/agent-issue-batch.d.ts +17 -0
- package/dist/services/agent-issue-batch.d.ts.map +1 -0
- package/dist/services/agent-issue-batch.js +57 -0
- package/dist/services/agent-issue-batch.js.map +1 -0
- package/dist/services/controller.d.ts +4 -0
- package/dist/services/controller.d.ts.map +1 -0
- package/dist/services/controller.js +237 -0
- package/dist/services/controller.js.map +1 -0
- package/dist/services/langgraph-runner.d.ts +33 -0
- package/dist/services/langgraph-runner.d.ts.map +1 -0
- package/dist/services/langgraph-runner.js +478 -0
- package/dist/services/langgraph-runner.js.map +1 -0
- package/dist/services/orchestrator.d.ts +9 -0
- package/dist/services/orchestrator.d.ts.map +1 -0
- package/dist/services/orchestrator.js +116 -0
- package/dist/services/orchestrator.js.map +1 -0
- package/dist/services/pre-controller.d.ts +7 -0
- package/dist/services/pre-controller.d.ts.map +1 -0
- package/dist/services/pre-controller.js +101 -0
- package/dist/services/pre-controller.js.map +1 -0
- package/dist/services/process-manager.d.ts +67 -0
- package/dist/services/process-manager.d.ts.map +1 -0
- package/dist/services/process-manager.js +938 -0
- package/dist/services/process-manager.js.map +1 -0
- package/dist/services/project-permissions.d.ts +84 -0
- package/dist/services/project-permissions.d.ts.map +1 -0
- package/dist/services/project-permissions.js +129 -0
- package/dist/services/project-permissions.js.map +1 -0
- package/dist/services/scheduler.d.ts +6 -0
- package/dist/services/scheduler.d.ts.map +1 -0
- package/dist/services/scheduler.js +300 -0
- package/dist/services/scheduler.js.map +1 -0
- package/dist/services/system-prompt.d.ts +3 -0
- package/dist/services/system-prompt.d.ts.map +1 -0
- package/dist/services/system-prompt.js +285 -0
- package/dist/services/system-prompt.js.map +1 -0
- package/dist/services/terminal.d.ts +18 -0
- package/dist/services/terminal.d.ts.map +1 -0
- package/dist/services/terminal.js +222 -0
- package/dist/services/terminal.js.map +1 -0
- package/dist/services/websocket.d.ts +15 -0
- package/dist/services/websocket.d.ts.map +1 -0
- package/dist/services/websocket.js +204 -0
- package/dist/services/websocket.js.map +1 -0
- package/dist/types.d.ts +108 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/env.ini +18 -0
- package/package.json +38 -0
- package/project_id +0 -0
- package/public/admin-users.html +188 -0
- package/public/agent.html +199 -0
- package/public/css/issues.css +275 -0
- package/public/css/style.css +1299 -0
- package/public/index.html +166 -0
- package/public/issue.html +76 -0
- package/public/js/agent.js +19 -0
- package/public/js/common.js +735 -0
- package/public/js/dashboard.js +772 -0
- package/public/js/files-panel.js +703 -0
- package/public/js/interactive-terminal.js +201 -0
- package/public/js/issue-renderer.js +559 -0
- package/public/js/issue.js +57 -0
- package/public/js/project.js +2425 -0
- package/public/js/terminal.js +564 -0
- package/public/project.html +430 -0
- package/public/terminal.html +67 -0
- package/public/vendor/marked.js +74 -0
- package/public/vendor/xterm-addon-fit.js +2 -0
- package/public/vendor/xterm.css +209 -0
- package/public/vendor/xterm.js +2 -0
- package/send_message_and_update_issue.js +65 -0
- package/tsconfig.json +19 -0
- package/update_round2_and_create_round3.js +284 -0
|
@@ -0,0 +1,733 @@
|
|
|
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
|
+
exports.isLocalhostSafeRoute = isLocalhostSafeRoute;
|
|
7
|
+
exports.isLocalhostRequest = isLocalhostRequest;
|
|
8
|
+
exports.isLocalhostBypassRequest = isLocalhostBypassRequest;
|
|
9
|
+
exports.isLegacyAuthUser = isLegacyAuthUser;
|
|
10
|
+
exports.getRequestUser = getRequestUser;
|
|
11
|
+
exports.setupAuth = setupAuth;
|
|
12
|
+
const node_crypto_1 = require("node:crypto");
|
|
13
|
+
const uuid_1 = require("uuid");
|
|
14
|
+
const fs_1 = __importDefault(require("fs"));
|
|
15
|
+
const path_1 = __importDefault(require("path"));
|
|
16
|
+
const os_1 = __importDefault(require("os"));
|
|
17
|
+
const logger_1 = __importDefault(require("../logger"));
|
|
18
|
+
const COOKIE_NAME = 'argus-auth';
|
|
19
|
+
const CONFIG_DIR = path_1.default.join(os_1.default.homedir(), '.argus');
|
|
20
|
+
const CONFIG_PATH = path_1.default.join(CONFIG_DIR, 'config.json');
|
|
21
|
+
// --- Password hashing with scrypt ---
|
|
22
|
+
function hashPassword(pwd, salt) {
|
|
23
|
+
const s = salt || (0, node_crypto_1.randomBytes)(16).toString('hex');
|
|
24
|
+
const derived = (0, node_crypto_1.scryptSync)(pwd, s, 64).toString('hex');
|
|
25
|
+
return { hash: derived, salt: s };
|
|
26
|
+
}
|
|
27
|
+
function verifyPassword(pwd, storedHash, salt) {
|
|
28
|
+
const { hash } = hashPassword(pwd, salt);
|
|
29
|
+
const a = Buffer.from(hash, 'hex');
|
|
30
|
+
const b = Buffer.from(storedHash, 'hex');
|
|
31
|
+
if (a.length !== b.length)
|
|
32
|
+
return false;
|
|
33
|
+
return (0, node_crypto_1.timingSafeEqual)(a, b);
|
|
34
|
+
}
|
|
35
|
+
// Backward compat: detect old SHA-256 hashes (64 hex chars, no salt)
|
|
36
|
+
function isLegacySha256(config) {
|
|
37
|
+
return !!config.passwordHash && !config.passwordSalt;
|
|
38
|
+
}
|
|
39
|
+
function legacySha256(pwd) {
|
|
40
|
+
return (0, node_crypto_1.createHash)('sha256').update(pwd).digest('hex');
|
|
41
|
+
}
|
|
42
|
+
function parseCookies(header) {
|
|
43
|
+
const cookies = {};
|
|
44
|
+
if (!header)
|
|
45
|
+
return cookies;
|
|
46
|
+
for (const part of header.split(';')) {
|
|
47
|
+
const idx = part.indexOf('=');
|
|
48
|
+
if (idx === -1)
|
|
49
|
+
continue;
|
|
50
|
+
cookies[part.slice(0, idx).trim()] = part.slice(idx + 1).trim();
|
|
51
|
+
}
|
|
52
|
+
return cookies;
|
|
53
|
+
}
|
|
54
|
+
function loadAuthConfig() {
|
|
55
|
+
try {
|
|
56
|
+
const { getDatabase } = require('../db/database');
|
|
57
|
+
const db = getDatabase();
|
|
58
|
+
const row = db.prepare("SELECT value FROM settings WHERE key = 'auth'").get();
|
|
59
|
+
if (row)
|
|
60
|
+
return JSON.parse(row.value);
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
logger_1.default.error(e, 'Failed to load auth config from database');
|
|
64
|
+
}
|
|
65
|
+
// Fallback: try legacy file config and migrate
|
|
66
|
+
try {
|
|
67
|
+
if (fs_1.default.existsSync(CONFIG_PATH)) {
|
|
68
|
+
const config = JSON.parse(fs_1.default.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
69
|
+
if (config.passwordHash) {
|
|
70
|
+
logger_1.default.info('Migrating auth config from file to database');
|
|
71
|
+
saveAuthConfig(config);
|
|
72
|
+
return config;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch { }
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
function saveAuthConfig(config) {
|
|
80
|
+
try {
|
|
81
|
+
const { getDatabase } = require('../db/database');
|
|
82
|
+
const db = getDatabase();
|
|
83
|
+
db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('auth', ?)").run(JSON.stringify(config));
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
logger_1.default.error(e, 'Failed to save auth config to database');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// --- Localhost bypass: only safe (agent-usable) routes ---
|
|
90
|
+
const LOCALHOST_SAFE_PREFIXES = [
|
|
91
|
+
'/api/projects', // project CRUD and sub-resources (issues, agents, etc.)
|
|
92
|
+
'/api/issues/', // issue CRUD + comments
|
|
93
|
+
'/api/agents/', // agent status/logs (GET), start/stop (POST)
|
|
94
|
+
'/api/comments/', // comment editing
|
|
95
|
+
'/api/milestones', // milestone CRUD
|
|
96
|
+
'/api/notifications',
|
|
97
|
+
'/api/reactions/',
|
|
98
|
+
'/api/inbox',
|
|
99
|
+
'/api/knowledge/',
|
|
100
|
+
'/api/my-issues',
|
|
101
|
+
];
|
|
102
|
+
// Admin-only operations that localhost should NOT bypass
|
|
103
|
+
const LOCALHOST_BLOCKED_PATTERNS = [
|
|
104
|
+
{ method: 'POST', prefix: '/api/auth/' },
|
|
105
|
+
{ method: 'GET', prefix: '/api/auth/' },
|
|
106
|
+
];
|
|
107
|
+
function isLocalhostSafeRoute(method, url) {
|
|
108
|
+
for (const pattern of LOCALHOST_BLOCKED_PATTERNS) {
|
|
109
|
+
if (method === pattern.method && url.startsWith(pattern.prefix)) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const prefix of LOCALHOST_SAFE_PREFIXES) {
|
|
114
|
+
if (url.startsWith(prefix))
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
if (url.startsWith('/ws/'))
|
|
118
|
+
return true;
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
function isLocalhostRequest(request) {
|
|
122
|
+
const remoteIp = request.ip;
|
|
123
|
+
return remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1';
|
|
124
|
+
}
|
|
125
|
+
function isLocalhostBypassRequest(request) {
|
|
126
|
+
return isLocalhostRequest(request) && isLocalhostSafeRoute(request.method, request.url);
|
|
127
|
+
}
|
|
128
|
+
function isLegacyAuthUser(user) {
|
|
129
|
+
return !!user && user.id === 'legacy';
|
|
130
|
+
}
|
|
131
|
+
function getRequestToken(request) {
|
|
132
|
+
const cookies = parseCookies(request.headers.cookie);
|
|
133
|
+
let token = cookies[COOKIE_NAME];
|
|
134
|
+
if (!token) {
|
|
135
|
+
const authHeader = request.headers.authorization;
|
|
136
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
137
|
+
token = authHeader.slice(7);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return token || null;
|
|
141
|
+
}
|
|
142
|
+
function getRequestUser(request) {
|
|
143
|
+
try {
|
|
144
|
+
const token = getRequestToken(request);
|
|
145
|
+
if (!token)
|
|
146
|
+
return null;
|
|
147
|
+
const { getDatabase } = require('../db/database');
|
|
148
|
+
const db = getDatabase();
|
|
149
|
+
const session = db.prepare('SELECT user_id FROM sessions WHERE token = ? AND expires_at > ?').get(token, Date.now());
|
|
150
|
+
if (session?.user_id) {
|
|
151
|
+
return db.prepare('SELECT * FROM users WHERE id = ?').get(session.user_id) || null;
|
|
152
|
+
}
|
|
153
|
+
const currentAuthConfig = loadAuthConfig();
|
|
154
|
+
if (currentAuthConfig.passwordHash && token === currentAuthConfig.passwordHash) {
|
|
155
|
+
return {
|
|
156
|
+
id: 'legacy',
|
|
157
|
+
username: 'admin',
|
|
158
|
+
email: '',
|
|
159
|
+
password_hash: '',
|
|
160
|
+
password_salt: '',
|
|
161
|
+
display_name: 'Admin',
|
|
162
|
+
role: 'admin',
|
|
163
|
+
created_at: '',
|
|
164
|
+
last_login_at: null,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// --- HTML pages ---
|
|
174
|
+
const THEME_SCRIPT = `<script>
|
|
175
|
+
(function() {
|
|
176
|
+
var themes = {
|
|
177
|
+
'github-dark': { bg:'#0d1117', fg:'#e6edf3', headerBg:'#161b22', border:'#30363d', textSecondary:'#8b949e', accent:'#58a6ff', error:'#f85149' },
|
|
178
|
+
'dracula': { bg:'#282a36', fg:'#f8f8f2', headerBg:'#21222c', border:'#44475a', textSecondary:'#6272a4', accent:'#8be9fd', error:'#ff5555' },
|
|
179
|
+
'nord': { bg:'#2e3440', fg:'#d8dee9', headerBg:'#3b4252', border:'#4c566a', textSecondary:'#81a1c1', accent:'#88c0d0', error:'#bf616a' },
|
|
180
|
+
'monokai': { bg:'#272822', fg:'#f8f8f2', headerBg:'#1e1f1c', border:'#3e3d32', textSecondary:'#75715e', accent:'#66d9ef', error:'#f92672' },
|
|
181
|
+
'solarized-dark': { bg:'#002b36', fg:'#839496', headerBg:'#073642', border:'#586e75', textSecondary:'#657b83', accent:'#268bd2', error:'#dc322f' },
|
|
182
|
+
'solarized-light': { bg:'#fdf6e3', fg:'#073642', headerBg:'#eee8d5', border:'#c9bba3', textSecondary:'#586e75', accent:'#268bd2', error:'#dc322f' }
|
|
183
|
+
};
|
|
184
|
+
var name = null;
|
|
185
|
+
try { name = localStorage.getItem('argus-theme'); } catch(e) {}
|
|
186
|
+
var t = themes[name] || themes['solarized-light'];
|
|
187
|
+
var r = document.documentElement;
|
|
188
|
+
r.style.setProperty('--bg', t.bg);
|
|
189
|
+
r.style.setProperty('--fg', t.fg);
|
|
190
|
+
r.style.setProperty('--header-bg', t.headerBg);
|
|
191
|
+
r.style.setProperty('--border', t.border);
|
|
192
|
+
r.style.setProperty('--text-secondary', t.textSecondary);
|
|
193
|
+
r.style.setProperty('--accent', t.accent);
|
|
194
|
+
r.style.setProperty('--error', t.error);
|
|
195
|
+
})();
|
|
196
|
+
</script>`;
|
|
197
|
+
const PAGE_STYLE = `
|
|
198
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
199
|
+
body { font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Fira Code', Menlo, monospace; background: var(--bg, #0d1117); color: var(--fg, #e6edf3); display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
|
200
|
+
.card { background: var(--header-bg, #161b22); border: 1px solid var(--border, #30363d); border-radius: 12px; padding: 2rem; width: 100%; max-width: 360px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); }
|
|
201
|
+
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; text-align: center; }
|
|
202
|
+
h1 span { color: var(--accent, #58a6ff); }
|
|
203
|
+
.subtitle { font-size: 0.875rem; color: var(--text-secondary, #8b949e); text-align: center; margin-bottom: 1.5rem; }
|
|
204
|
+
label { display: block; margin-bottom: 0.5rem; font-size: 0.875rem; color: var(--text-secondary, #8b949e); }
|
|
205
|
+
input[type="password"] { width: 100%; padding: 0.75rem; border: 1px solid var(--border, #30363d); border-radius: 8px; background: var(--bg, #0d1117); color: var(--fg, #e6edf3); font-size: 1rem; outline: none; margin-bottom: 0.75rem; font-family: inherit; }
|
|
206
|
+
input[type="password"]:focus { border-color: var(--accent, #58a6ff); }
|
|
207
|
+
button { width: 100%; padding: 0.75rem; margin-top: 0.5rem; border: none; border-radius: 8px; background: #238636; color: #fff; font-size: 1rem; cursor: pointer; font-weight: 600; font-family: inherit; }
|
|
208
|
+
button:hover { background: #2ea043; }
|
|
209
|
+
.error { color: var(--error, #f85149); font-size: 0.875rem; margin-top: 0.75rem; text-align: center; display: none; }
|
|
210
|
+
.success { color: var(--accent, #58a6ff); font-size: 0.875rem; margin-top: 0.75rem; text-align: center; display: none; }
|
|
211
|
+
`;
|
|
212
|
+
const SETUP_HTML = `<!DOCTYPE html>
|
|
213
|
+
<html lang="en">
|
|
214
|
+
<head>
|
|
215
|
+
<meta charset="UTF-8">
|
|
216
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
217
|
+
<title>Argus — Setup</title>
|
|
218
|
+
<style>${PAGE_STYLE}</style>
|
|
219
|
+
${THEME_SCRIPT}
|
|
220
|
+
</head>
|
|
221
|
+
<body>
|
|
222
|
+
<div class="card">
|
|
223
|
+
<h1><span>Argus</span></h1>
|
|
224
|
+
<p class="subtitle">Set a password to protect your platform</p>
|
|
225
|
+
<form id="form">
|
|
226
|
+
<label for="password">Password</label>
|
|
227
|
+
<input type="password" id="password" name="password" placeholder="Enter password (min 4 chars)" autofocus required>
|
|
228
|
+
<label for="confirm">Confirm password</label>
|
|
229
|
+
<input type="password" id="confirm" name="confirm" placeholder="Confirm password" required>
|
|
230
|
+
<button type="submit">Set Password</button>
|
|
231
|
+
<div class="error" id="error"></div>
|
|
232
|
+
</form>
|
|
233
|
+
</div>
|
|
234
|
+
<script>
|
|
235
|
+
document.getElementById('form').addEventListener('submit', async (e) => {
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
const errEl = document.getElementById('error');
|
|
238
|
+
const password = document.getElementById('password').value;
|
|
239
|
+
const confirm = document.getElementById('confirm').value;
|
|
240
|
+
if (password.length < 4) { errEl.textContent = 'Password must be at least 4 characters'; errEl.style.display = 'block'; return; }
|
|
241
|
+
if (password !== confirm) { errEl.textContent = 'Passwords do not match'; errEl.style.display = 'block'; return; }
|
|
242
|
+
const res = await fetch('/api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
|
|
243
|
+
if (res.ok) { window.location.href = '/'; } else { const data = await res.json(); errEl.textContent = data.error || 'Setup failed'; errEl.style.display = 'block'; }
|
|
244
|
+
});
|
|
245
|
+
</script>
|
|
246
|
+
</body>
|
|
247
|
+
</html>`;
|
|
248
|
+
const LOGIN_HTML = `<!DOCTYPE html>
|
|
249
|
+
<html lang="en">
|
|
250
|
+
<head>
|
|
251
|
+
<meta charset="UTF-8">
|
|
252
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
253
|
+
<title>Argus — Login</title>
|
|
254
|
+
<style>${PAGE_STYLE}
|
|
255
|
+
input[type="text"] { width: 100%; padding: 0.75rem; border: 1px solid var(--border, #30363d); border-radius: 8px; background: var(--bg, #0d1117); color: var(--fg, #e6edf3); font-size: 1rem; outline: none; margin-bottom: 0.75rem; font-family: inherit; }
|
|
256
|
+
input[type="text"]:focus { border-color: var(--accent, #58a6ff); }
|
|
257
|
+
</style>
|
|
258
|
+
${THEME_SCRIPT}
|
|
259
|
+
</head>
|
|
260
|
+
<body>
|
|
261
|
+
<div class="card">
|
|
262
|
+
<h1><span>Argus</span></h1>
|
|
263
|
+
<form id="form">
|
|
264
|
+
<div id="username-field">
|
|
265
|
+
<label for="username">Username</label>
|
|
266
|
+
<input type="text" id="username" name="username" autofocus required>
|
|
267
|
+
</div>
|
|
268
|
+
<label for="password">Password</label>
|
|
269
|
+
<input type="password" id="password" name="password" required>
|
|
270
|
+
<button type="submit">Login</button>
|
|
271
|
+
<div class="error" id="error"></div>
|
|
272
|
+
<p style="text-align:center;margin-top:1rem;font-size:0.875rem;color:var(--text-secondary,#8b949e)">Don't have an account? <a href="/register" style="color:var(--accent,#58a6ff)">Register</a></p>
|
|
273
|
+
</form>
|
|
274
|
+
</div>
|
|
275
|
+
<script>
|
|
276
|
+
document.getElementById('form').addEventListener('submit', async (e) => {
|
|
277
|
+
e.preventDefault();
|
|
278
|
+
const errEl = document.getElementById('error');
|
|
279
|
+
errEl.style.display = 'none';
|
|
280
|
+
const username = document.getElementById('username')?.value;
|
|
281
|
+
const password = document.getElementById('password').value;
|
|
282
|
+
// Try multi-user login first
|
|
283
|
+
if (username) {
|
|
284
|
+
const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
|
|
285
|
+
if (res.ok) { window.location.href = '/'; return; }
|
|
286
|
+
}
|
|
287
|
+
// Fallback: legacy single-password login
|
|
288
|
+
const res2 = await fetch('/api/auth', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
|
|
289
|
+
if (res2.ok) { window.location.href = '/'; return; }
|
|
290
|
+
errEl.textContent = 'Invalid username or password';
|
|
291
|
+
errEl.style.display = 'block';
|
|
292
|
+
});
|
|
293
|
+
</script>
|
|
294
|
+
</body>
|
|
295
|
+
</html>`;
|
|
296
|
+
const REGISTER_HTML = `<!DOCTYPE html>
|
|
297
|
+
<html lang="en">
|
|
298
|
+
<head>
|
|
299
|
+
<meta charset="UTF-8">
|
|
300
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
301
|
+
<title>Argus — Register</title>
|
|
302
|
+
<style>${PAGE_STYLE}</style>
|
|
303
|
+
${THEME_SCRIPT}
|
|
304
|
+
</head>
|
|
305
|
+
<body>
|
|
306
|
+
<div class="card">
|
|
307
|
+
<h1><span>Argus</span></h1>
|
|
308
|
+
<p class="subtitle">Create your account</p>
|
|
309
|
+
<form id="form">
|
|
310
|
+
<label for="username">Username</label>
|
|
311
|
+
<input type="text" id="username" name="username" placeholder="2-32 characters" autofocus required style="width:100%;padding:0.75rem;border:1px solid var(--border,#30363d);border-radius:8px;background:var(--bg,#0d1117);color:var(--fg,#e6edf3);font-size:1rem;outline:none;margin-bottom:0.75rem;font-family:inherit;">
|
|
312
|
+
<label for="display_name">Display Name (optional)</label>
|
|
313
|
+
<input type="text" id="display_name" name="display_name" style="width:100%;padding:0.75rem;border:1px solid var(--border,#30363d);border-radius:8px;background:var(--bg,#0d1117);color:var(--fg,#e6edf3);font-size:1rem;outline:none;margin-bottom:0.75rem;font-family:inherit;">
|
|
314
|
+
<label for="password">Password</label>
|
|
315
|
+
<input type="password" id="password" name="password" placeholder="Min 4 characters" required>
|
|
316
|
+
<label for="confirm">Confirm Password</label>
|
|
317
|
+
<input type="password" id="confirm" name="confirm" required>
|
|
318
|
+
<button type="submit">Register</button>
|
|
319
|
+
<div class="error" id="error"></div>
|
|
320
|
+
<p style="text-align:center;margin-top:1rem;font-size:0.875rem;color:var(--text-secondary)">Already have an account? <a href="/login" style="color:var(--accent)">Login</a></p>
|
|
321
|
+
</form>
|
|
322
|
+
</div>
|
|
323
|
+
<script>
|
|
324
|
+
document.getElementById('form').addEventListener('submit', async (e) => {
|
|
325
|
+
e.preventDefault();
|
|
326
|
+
const errEl = document.getElementById('error');
|
|
327
|
+
const username = document.getElementById('username').value;
|
|
328
|
+
const display_name = document.getElementById('display_name').value;
|
|
329
|
+
const password = document.getElementById('password').value;
|
|
330
|
+
const confirm = document.getElementById('confirm').value;
|
|
331
|
+
if (password.length < 4) { errEl.textContent = 'Password must be at least 4 characters'; errEl.style.display = 'block'; return; }
|
|
332
|
+
if (password !== confirm) { errEl.textContent = 'Passwords do not match'; errEl.style.display = 'block'; return; }
|
|
333
|
+
const res = await fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, display_name: display_name || undefined }) });
|
|
334
|
+
if (res.ok) { window.location.href = '/'; } else { const data = await res.json(); errEl.textContent = data.error || 'Registration failed'; errEl.style.display = 'block'; }
|
|
335
|
+
});
|
|
336
|
+
</script>
|
|
337
|
+
</body>
|
|
338
|
+
</html>`;
|
|
339
|
+
const CHANGE_PASSWORD_HTML = `<!DOCTYPE html>
|
|
340
|
+
<html lang="en">
|
|
341
|
+
<head>
|
|
342
|
+
<meta charset="UTF-8">
|
|
343
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
344
|
+
<title>Argus — Change Password</title>
|
|
345
|
+
<style>${PAGE_STYLE}</style>
|
|
346
|
+
${THEME_SCRIPT}
|
|
347
|
+
</head>
|
|
348
|
+
<body>
|
|
349
|
+
<div class="card">
|
|
350
|
+
<h1><span>Argus</span></h1>
|
|
351
|
+
<p class="subtitle">Change your password</p>
|
|
352
|
+
<form id="form">
|
|
353
|
+
<label for="current">Current password</label>
|
|
354
|
+
<input type="password" id="current" name="current" autofocus required>
|
|
355
|
+
<label for="password">New password</label>
|
|
356
|
+
<input type="password" id="password" name="password" placeholder="Min 4 characters" required>
|
|
357
|
+
<label for="confirm">Confirm new password</label>
|
|
358
|
+
<input type="password" id="confirm" name="confirm" required>
|
|
359
|
+
<button type="submit">Change Password</button>
|
|
360
|
+
<div class="error" id="error"></div>
|
|
361
|
+
<div class="success" id="success">Password changed successfully</div>
|
|
362
|
+
</form>
|
|
363
|
+
</div>
|
|
364
|
+
<script>
|
|
365
|
+
document.getElementById('form').addEventListener('submit', async (e) => {
|
|
366
|
+
e.preventDefault();
|
|
367
|
+
const errEl = document.getElementById('error');
|
|
368
|
+
const successEl = document.getElementById('success');
|
|
369
|
+
errEl.style.display = 'none'; successEl.style.display = 'none';
|
|
370
|
+
const current = document.getElementById('current').value;
|
|
371
|
+
const password = document.getElementById('password').value;
|
|
372
|
+
const confirm = document.getElementById('confirm').value;
|
|
373
|
+
if (password.length < 4) { errEl.textContent = 'New password must be at least 4 characters'; errEl.style.display = 'block'; return; }
|
|
374
|
+
if (password !== confirm) { errEl.textContent = 'Passwords do not match'; errEl.style.display = 'block'; return; }
|
|
375
|
+
const res = await fetch('/api/auth/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, password }) });
|
|
376
|
+
if (res.ok) { successEl.style.display = 'block'; document.getElementById('form').reset(); } else { const data = await res.json(); errEl.textContent = data.error || 'Failed'; errEl.style.display = 'block'; }
|
|
377
|
+
});
|
|
378
|
+
</script>
|
|
379
|
+
</body>
|
|
380
|
+
</html>`;
|
|
381
|
+
/**
|
|
382
|
+
* Simplified auth: cookie stores passwordHash directly, no server-side sessions.
|
|
383
|
+
* Follows the same pattern as swarmie for maximum reliability.
|
|
384
|
+
*/
|
|
385
|
+
function setupAuth(app) {
|
|
386
|
+
let authConfig = loadAuthConfig();
|
|
387
|
+
// Ensure sessions table has user_id column (migration may not have been applied yet)
|
|
388
|
+
try {
|
|
389
|
+
const { getDatabase } = require('../db/database');
|
|
390
|
+
const db = getDatabase();
|
|
391
|
+
const sessionCols = db.prepare("PRAGMA table_info(sessions)").all();
|
|
392
|
+
if (!sessionCols.find((c) => c.name === 'user_id')) {
|
|
393
|
+
db.exec("ALTER TABLE sessions ADD COLUMN user_id TEXT REFERENCES users(id) ON DELETE CASCADE");
|
|
394
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)");
|
|
395
|
+
logger_1.default.info('Auth: applied sessions.user_id migration');
|
|
396
|
+
}
|
|
397
|
+
// Backfill sessions with NULL user_id: if only one user exists, assign all orphan sessions to them
|
|
398
|
+
const nullCount = db.prepare("SELECT COUNT(*) as c FROM sessions WHERE user_id IS NULL").get().c;
|
|
399
|
+
if (nullCount > 0) {
|
|
400
|
+
const users = db.prepare("SELECT id FROM users").all();
|
|
401
|
+
if (users.length === 1) {
|
|
402
|
+
db.prepare("UPDATE sessions SET user_id = ? WHERE user_id IS NULL").run(users[0].id);
|
|
403
|
+
logger_1.default.info(`Auth: backfilled ${nullCount} sessions with user_id=${users[0].id}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
catch (e) {
|
|
408
|
+
logger_1.default.warn(e, 'Failed to check/apply sessions migration');
|
|
409
|
+
}
|
|
410
|
+
function checkPassword(pwd) {
|
|
411
|
+
if (!authConfig.passwordHash)
|
|
412
|
+
return false;
|
|
413
|
+
if (isLegacySha256(authConfig)) {
|
|
414
|
+
return legacySha256(pwd) === authConfig.passwordHash;
|
|
415
|
+
}
|
|
416
|
+
return verifyPassword(pwd, authConfig.passwordHash, authConfig.passwordSalt);
|
|
417
|
+
}
|
|
418
|
+
function setPassword(pwd) {
|
|
419
|
+
const { hash, salt } = hashPassword(pwd);
|
|
420
|
+
authConfig = { passwordHash: hash, passwordSalt: salt };
|
|
421
|
+
saveAuthConfig(authConfig);
|
|
422
|
+
}
|
|
423
|
+
function setAuthCookie(reply) {
|
|
424
|
+
reply.header('Set-Cookie', `${COOKIE_NAME}=${authConfig.passwordHash}; HttpOnly; Path=/; SameSite=Lax`);
|
|
425
|
+
}
|
|
426
|
+
function isValidToken(token) {
|
|
427
|
+
return !!authConfig.passwordHash && token === authConfig.passwordHash;
|
|
428
|
+
}
|
|
429
|
+
// Setup page
|
|
430
|
+
app.get('/setup', async (_req, reply) => {
|
|
431
|
+
if (authConfig.passwordHash)
|
|
432
|
+
return reply.redirect('/login');
|
|
433
|
+
reply.type('text/html').send(SETUP_HTML);
|
|
434
|
+
});
|
|
435
|
+
// Setup endpoint
|
|
436
|
+
app.post('/api/auth/setup', async (request, reply) => {
|
|
437
|
+
if (authConfig.passwordHash)
|
|
438
|
+
return reply.status(403).send({ error: 'Password already set' });
|
|
439
|
+
const body = request.body;
|
|
440
|
+
if (!body?.password || body.password.length < 4) {
|
|
441
|
+
return reply.status(400).send({ error: 'Password must be at least 4 characters' });
|
|
442
|
+
}
|
|
443
|
+
setPassword(body.password);
|
|
444
|
+
logger_1.default.info('Password has been set');
|
|
445
|
+
setAuthCookie(reply);
|
|
446
|
+
reply.send({ ok: true });
|
|
447
|
+
});
|
|
448
|
+
// Register page
|
|
449
|
+
app.get('/register', async (_req, reply) => {
|
|
450
|
+
reply.type('text/html').send(REGISTER_HTML);
|
|
451
|
+
});
|
|
452
|
+
// Login page
|
|
453
|
+
app.get('/login', async (_req, reply) => {
|
|
454
|
+
if (!authConfig.passwordHash) {
|
|
455
|
+
// Check if multi-user mode has users
|
|
456
|
+
let hasUsers = false;
|
|
457
|
+
try {
|
|
458
|
+
const { getDatabase } = require('../db/database');
|
|
459
|
+
const db = getDatabase();
|
|
460
|
+
hasUsers = db.prepare('SELECT COUNT(*) as c FROM users').get().c > 0;
|
|
461
|
+
}
|
|
462
|
+
catch { }
|
|
463
|
+
if (!hasUsers)
|
|
464
|
+
return reply.redirect('/register');
|
|
465
|
+
}
|
|
466
|
+
reply.type('text/html').send(LOGIN_HTML);
|
|
467
|
+
});
|
|
468
|
+
// Login endpoint
|
|
469
|
+
app.post('/api/auth', async (request, reply) => {
|
|
470
|
+
if (!authConfig.passwordHash)
|
|
471
|
+
return reply.status(400).send({ error: 'No password configured' });
|
|
472
|
+
const body = request.body;
|
|
473
|
+
if (body?.password && checkPassword(body.password)) {
|
|
474
|
+
// Auto-migrate legacy SHA-256 to scrypt on successful login
|
|
475
|
+
if (isLegacySha256(authConfig)) {
|
|
476
|
+
setPassword(body.password);
|
|
477
|
+
logger_1.default.info('Migrated password hash from SHA-256 to scrypt');
|
|
478
|
+
}
|
|
479
|
+
setAuthCookie(reply);
|
|
480
|
+
reply.send({ ok: true, token: authConfig.passwordHash });
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
reply.status(401).send({ error: 'Invalid password' });
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
// Change password page
|
|
487
|
+
app.get('/change-password', async (_req, reply) => {
|
|
488
|
+
reply.type('text/html').send(CHANGE_PASSWORD_HTML);
|
|
489
|
+
});
|
|
490
|
+
// Change password endpoint
|
|
491
|
+
app.post('/api/auth/change-password', async (request, reply) => {
|
|
492
|
+
if (!authConfig.passwordHash)
|
|
493
|
+
return reply.status(400).send({ error: 'No password configured' });
|
|
494
|
+
const body = request.body;
|
|
495
|
+
if (!body?.current || !checkPassword(body.current)) {
|
|
496
|
+
return reply.status(401).send({ error: 'Current password is incorrect' });
|
|
497
|
+
}
|
|
498
|
+
if (!body.password || body.password.length < 4) {
|
|
499
|
+
return reply.status(400).send({ error: 'New password must be at least 4 characters' });
|
|
500
|
+
}
|
|
501
|
+
setPassword(body.password);
|
|
502
|
+
logger_1.default.info('Password has been changed');
|
|
503
|
+
setAuthCookie(reply);
|
|
504
|
+
reply.send({ ok: true });
|
|
505
|
+
});
|
|
506
|
+
// Logout
|
|
507
|
+
app.post('/api/auth/logout', async (request, reply) => {
|
|
508
|
+
// Clean up session token from DB
|
|
509
|
+
const cookies = parseCookies(request.headers.cookie);
|
|
510
|
+
const token = cookies[COOKIE_NAME];
|
|
511
|
+
if (token) {
|
|
512
|
+
try {
|
|
513
|
+
const { getDatabase } = require('../db/database');
|
|
514
|
+
const db = getDatabase();
|
|
515
|
+
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
|
516
|
+
}
|
|
517
|
+
catch { }
|
|
518
|
+
}
|
|
519
|
+
reply.header('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0`).send({ ok: true });
|
|
520
|
+
});
|
|
521
|
+
// --- Multi-user API ---
|
|
522
|
+
// Register user (first user becomes admin)
|
|
523
|
+
app.post('/api/auth/register', async (request, reply) => {
|
|
524
|
+
const body = request.body;
|
|
525
|
+
if (!body?.username || !body?.password) {
|
|
526
|
+
return reply.status(400).send({ error: 'username and password are required' });
|
|
527
|
+
}
|
|
528
|
+
if (body.password.length < 4) {
|
|
529
|
+
return reply.status(400).send({ error: 'Password must be at least 4 characters' });
|
|
530
|
+
}
|
|
531
|
+
if (!/^[a-zA-Z0-9_-]{2,32}$/.test(body.username)) {
|
|
532
|
+
return reply.status(400).send({ error: 'Username must be 2-32 characters (letters, numbers, -, _)' });
|
|
533
|
+
}
|
|
534
|
+
const { getDatabase } = require('../db/database');
|
|
535
|
+
const db = getDatabase();
|
|
536
|
+
// Check if username exists
|
|
537
|
+
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(body.username);
|
|
538
|
+
if (existing)
|
|
539
|
+
return reply.status(409).send({ error: 'Username already taken' });
|
|
540
|
+
// First user becomes admin
|
|
541
|
+
const userCount = db.prepare('SELECT COUNT(*) as c FROM users').get().c;
|
|
542
|
+
const role = userCount === 0 ? 'admin' : 'member';
|
|
543
|
+
const userId = (0, uuid_1.v4)();
|
|
544
|
+
const { hash, salt } = hashPassword(body.password);
|
|
545
|
+
db.prepare('INSERT INTO users (id, username, email, password_hash, password_salt, display_name, role) VALUES (?, ?, ?, ?, ?, ?, ?)').run(userId, body.username, body.email || '', hash, salt, body.display_name || body.username, role);
|
|
546
|
+
// Auto-login: create session
|
|
547
|
+
const sessionToken = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
548
|
+
const now = Date.now();
|
|
549
|
+
const expiresAt = now + 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
550
|
+
db.prepare('INSERT INTO sessions (token, user_id, csrf_token, created_at, expires_at) VALUES (?, ?, ?, ?, ?)')
|
|
551
|
+
.run(sessionToken, userId, (0, node_crypto_1.randomBytes)(16).toString('hex'), now, expiresAt);
|
|
552
|
+
reply.header('Set-Cookie', `${COOKIE_NAME}=${sessionToken}; HttpOnly; Path=/; SameSite=Lax`);
|
|
553
|
+
const user = db.prepare('SELECT id, username, email, display_name, role, created_at FROM users WHERE id = ?').get(userId);
|
|
554
|
+
return reply.status(201).send({ ok: true, user, token: sessionToken });
|
|
555
|
+
});
|
|
556
|
+
// Login with username + password (multi-user)
|
|
557
|
+
app.post('/api/auth/login', async (request, reply) => {
|
|
558
|
+
const body = request.body;
|
|
559
|
+
if (!body?.username || !body?.password) {
|
|
560
|
+
return reply.status(400).send({ error: 'username and password are required' });
|
|
561
|
+
}
|
|
562
|
+
const { getDatabase } = require('../db/database');
|
|
563
|
+
const db = getDatabase();
|
|
564
|
+
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(body.username);
|
|
565
|
+
if (!user || !verifyPassword(body.password, user.password_hash, user.password_salt)) {
|
|
566
|
+
return reply.status(401).send({ error: 'Invalid username or password' });
|
|
567
|
+
}
|
|
568
|
+
// Update last_login_at
|
|
569
|
+
db.prepare("UPDATE users SET last_login_at = datetime('now') WHERE id = ?").run(user.id);
|
|
570
|
+
// Create session
|
|
571
|
+
const sessionToken = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
572
|
+
const now = Date.now();
|
|
573
|
+
const expiresAt = now + 30 * 24 * 60 * 60 * 1000;
|
|
574
|
+
db.prepare('INSERT INTO sessions (token, user_id, csrf_token, created_at, expires_at) VALUES (?, ?, ?, ?, ?)')
|
|
575
|
+
.run(sessionToken, user.id, (0, node_crypto_1.randomBytes)(16).toString('hex'), now, expiresAt);
|
|
576
|
+
reply.header('Set-Cookie', `${COOKIE_NAME}=${sessionToken}; HttpOnly; Path=/; SameSite=Lax`);
|
|
577
|
+
return { ok: true, token: sessionToken, user: { id: user.id, username: user.username, email: user.email, display_name: user.display_name, role: user.role } };
|
|
578
|
+
});
|
|
579
|
+
// Get current user info
|
|
580
|
+
app.get('/api/auth/me', async (request, reply) => {
|
|
581
|
+
// Try multi-user session first
|
|
582
|
+
const user = getUserFromRequest(request);
|
|
583
|
+
if (user) {
|
|
584
|
+
return { id: user.id, username: user.username, email: user.email, display_name: user.display_name, role: user.role, created_at: user.created_at };
|
|
585
|
+
}
|
|
586
|
+
// Fallback: legacy single-password auth (cookie = passwordHash)
|
|
587
|
+
const cookies = parseCookies(request.headers.cookie);
|
|
588
|
+
const token = cookies[COOKIE_NAME];
|
|
589
|
+
if (token && isValidToken(token)) {
|
|
590
|
+
return { id: 'legacy', username: 'admin', display_name: 'Admin', role: 'admin' };
|
|
591
|
+
}
|
|
592
|
+
return reply.status(401).send({ error: 'Not authenticated' });
|
|
593
|
+
});
|
|
594
|
+
// List users (admin only)
|
|
595
|
+
app.get('/api/auth/users', async (request, reply) => {
|
|
596
|
+
const user = getUserFromRequest(request);
|
|
597
|
+
if (!user || user.role !== 'admin')
|
|
598
|
+
return reply.status(403).send({ error: 'Admin access required' });
|
|
599
|
+
const { getDatabase } = require('../db/database');
|
|
600
|
+
const db = getDatabase();
|
|
601
|
+
const users = db.prepare('SELECT id, username, email, display_name, role, created_at, last_login_at FROM users ORDER BY created_at').all();
|
|
602
|
+
return { users };
|
|
603
|
+
});
|
|
604
|
+
// Update user role (admin only)
|
|
605
|
+
app.put('/api/auth/users/:id', async (request, reply) => {
|
|
606
|
+
const user = getUserFromRequest(request);
|
|
607
|
+
if (!user || user.role !== 'admin')
|
|
608
|
+
return reply.status(403).send({ error: 'Admin access required' });
|
|
609
|
+
const { id } = request.params;
|
|
610
|
+
const { role } = request.body;
|
|
611
|
+
if (id === user.id)
|
|
612
|
+
return reply.status(400).send({ error: 'Cannot change your own role' });
|
|
613
|
+
if (role && !['admin', 'member'].includes(role))
|
|
614
|
+
return reply.status(400).send({ error: 'Invalid role' });
|
|
615
|
+
const { getDatabase } = require('../db/database');
|
|
616
|
+
const db = getDatabase();
|
|
617
|
+
const target = db.prepare('SELECT id FROM users WHERE id = ?').get(id);
|
|
618
|
+
if (!target)
|
|
619
|
+
return reply.status(404).send({ error: 'User not found' });
|
|
620
|
+
if (role)
|
|
621
|
+
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
|
|
622
|
+
const updated = db.prepare('SELECT id, username, email, display_name, role, created_at, last_login_at FROM users WHERE id = ?').get(id);
|
|
623
|
+
return { user: updated };
|
|
624
|
+
});
|
|
625
|
+
// Delete user (admin only)
|
|
626
|
+
app.delete('/api/auth/users/:id', async (request, reply) => {
|
|
627
|
+
const user = getUserFromRequest(request);
|
|
628
|
+
if (!user || user.role !== 'admin')
|
|
629
|
+
return reply.status(403).send({ error: 'Admin access required' });
|
|
630
|
+
const { id } = request.params;
|
|
631
|
+
if (id === user.id)
|
|
632
|
+
return reply.status(400).send({ error: 'Cannot delete yourself' });
|
|
633
|
+
const { getDatabase } = require('../db/database');
|
|
634
|
+
const db = getDatabase();
|
|
635
|
+
const target = db.prepare('SELECT id FROM users WHERE id = ?').get(id);
|
|
636
|
+
if (!target)
|
|
637
|
+
return reply.status(404).send({ error: 'User not found' });
|
|
638
|
+
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id);
|
|
639
|
+
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
|
640
|
+
return { ok: true };
|
|
641
|
+
});
|
|
642
|
+
// Helper: resolve user from request token
|
|
643
|
+
function getUserFromRequest(request) {
|
|
644
|
+
return getRequestUser(request);
|
|
645
|
+
}
|
|
646
|
+
// Check if a session token is valid (for multi-user mode)
|
|
647
|
+
function isValidSessionToken(token) {
|
|
648
|
+
try {
|
|
649
|
+
const { getDatabase } = require('../db/database');
|
|
650
|
+
const db = getDatabase();
|
|
651
|
+
const session = db.prepare('SELECT token FROM sessions WHERE token = ? AND expires_at > ?').get(token, Date.now());
|
|
652
|
+
return !!session;
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
// Auth hook
|
|
659
|
+
app.addHook('onRequest', async (request, reply) => {
|
|
660
|
+
const url = request.url;
|
|
661
|
+
// ARGUS_NO_AUTH=true: skip all authentication
|
|
662
|
+
if (process.env.ARGUS_NO_AUTH === 'true') {
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
// Allow auth routes and favicon
|
|
666
|
+
if (request.method === 'OPTIONS' || url === '/login' || url === '/setup' || url === '/register' || url.startsWith('/api/auth') || url === '/favicon.ico') {
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
// Localhost bypass: only for agent-safe routes
|
|
670
|
+
if (isLocalhostBypassRequest(request)) {
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
// No password in memory -> reload from DB
|
|
674
|
+
if (!authConfig.passwordHash) {
|
|
675
|
+
authConfig = loadAuthConfig();
|
|
676
|
+
}
|
|
677
|
+
if (!authConfig.passwordHash) {
|
|
678
|
+
// Check if multi-user mode has users
|
|
679
|
+
let hasUsers = false;
|
|
680
|
+
try {
|
|
681
|
+
const { getDatabase } = require('../db/database');
|
|
682
|
+
const db = getDatabase();
|
|
683
|
+
const count = db.prepare('SELECT COUNT(*) as c FROM users').get().c;
|
|
684
|
+
hasUsers = count > 0;
|
|
685
|
+
}
|
|
686
|
+
catch { }
|
|
687
|
+
if (!hasUsers) {
|
|
688
|
+
if (url.startsWith('/api/') || url.startsWith('/ws')) {
|
|
689
|
+
reply.status(401).send({ error: 'No authentication configured. Visit /register to create the first account.' });
|
|
690
|
+
}
|
|
691
|
+
else {
|
|
692
|
+
reply.redirect('/register');
|
|
693
|
+
}
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
// Check cookie token
|
|
698
|
+
const cookies = parseCookies(request.headers.cookie);
|
|
699
|
+
const token = cookies[COOKIE_NAME];
|
|
700
|
+
// Try legacy single-password token first
|
|
701
|
+
if (token && isValidToken(token)) {
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
// Try multi-user session token
|
|
705
|
+
if (token && isValidSessionToken(token)) {
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
// Check Authorization: Bearer <token>
|
|
709
|
+
const authHeader = request.headers.authorization;
|
|
710
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
711
|
+
const bearerToken = authHeader.slice(7);
|
|
712
|
+
if (isValidToken(bearerToken))
|
|
713
|
+
return;
|
|
714
|
+
if (isValidSessionToken(bearerToken))
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
// Check query token (for WebSocket connections)
|
|
718
|
+
const queryToken = request.query?.token;
|
|
719
|
+
if (queryToken && (isValidToken(queryToken) || isValidSessionToken(queryToken)))
|
|
720
|
+
return;
|
|
721
|
+
// Allow static assets and UI page routes — only protect API/WS
|
|
722
|
+
if (url.startsWith('/public/') || url.startsWith('/css/') || url.startsWith('/js/') || url.startsWith('/vendor/')) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
// Unauthenticated page routes → redirect to login
|
|
726
|
+
if (!url.startsWith('/api/') && !url.startsWith('/ws')) {
|
|
727
|
+
return reply.redirect('/login');
|
|
728
|
+
}
|
|
729
|
+
// Unauthenticated API/WS
|
|
730
|
+
reply.status(401).send({ error: 'Unauthorized' });
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
//# sourceMappingURL=auth.js.map
|