fluxy-bot 0.8.9 → 0.9.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/README.md +53 -84
- package/bin/cli.js +1717 -6
- package/components.json +18 -18
- package/package.json +98 -109
- package/postcss.config.js +3 -3
- package/scripts/install.ps1 +1 -1
- package/scripts/install.sh +1 -1
- package/scripts/postinstall.js +47 -58
- package/shared/ai.ts +118 -202
- package/shared/config.ts +37 -39
- package/shared/logger.ts +6 -14
- package/shared/paths.ts +10 -16
- package/shared/relay.ts +89 -98
- package/supervisor/backend.ts +77 -89
- package/supervisor/chat/ARCHITECTURE.md +13 -14
- package/supervisor/chat/OnboardWizard.tsx +2194 -3419
- package/supervisor/chat/fluxy-main.tsx +495 -633
- package/supervisor/chat/fluxy.html +24 -36
- package/supervisor/chat/onboard-main.tsx +8 -8
- package/supervisor/chat/onboard.html +10 -13
- package/supervisor/chat/src/components/Chat/AudioBubble.tsx +94 -108
- package/supervisor/chat/src/components/Chat/ChatView.tsx +14 -36
- package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +71 -89
- package/supervisor/chat/src/components/Chat/InputBar.tsx +498 -728
- package/supervisor/chat/src/components/Chat/MessageBubble.tsx +154 -246
- package/supervisor/chat/src/components/Chat/MessageList.tsx +114 -138
- package/supervisor/chat/src/components/Chat/TypingIndicator.tsx +38 -50
- package/supervisor/chat/src/components/LoginScreen.tsx +245 -296
- package/supervisor/chat/src/hooks/useChat.ts +210 -259
- package/supervisor/chat/src/hooks/useFluxyChat.ts +232 -322
- package/supervisor/chat/src/hooks/useSpeechRecognition.ts +126 -148
- package/supervisor/chat/src/lib/auth.ts +20 -23
- package/supervisor/chat/src/lib/ws-client.ts +113 -125
- package/supervisor/chat/src/styles/globals.css +76 -85
- package/supervisor/file-saver.ts +39 -52
- package/supervisor/fluxy-agent.ts +213 -292
- package/supervisor/index.ts +895 -1263
- package/supervisor/scheduler.ts +237 -299
- package/supervisor/tunnel.ts +113 -167
- package/supervisor/vite-dev.ts +51 -61
- package/supervisor/widget.js +135 -147
- package/supervisor/worker.ts +45 -49
- package/tsconfig.json +18 -24
- package/vite.config.ts +48 -55
- package/vite.fluxy.config.ts +34 -34
- package/worker/claude-auth.ts +202 -265
- package/worker/codex-auth.ts +144 -181
- package/worker/db.ts +63 -191
- package/worker/index.ts +601 -842
- package/workspace/MYSELF.md +1 -1
- package/workspace/PULSE.json +6 -6
- package/workspace/backend/index.ts +16 -19
- package/workspace/client/index.html +47 -60
- package/workspace/client/public/manifest.json +21 -21
- package/workspace/client/public/sw.js +29 -31
- package/workspace/client/src/App.tsx +91 -136
- package/workspace/client/src/components/Dashboard/DashboardPage.tsx +68 -72
- package/workspace/client/src/components/ErrorBoundary.tsx +11 -11
- package/workspace/client/src/components/Layout/DashboardLayout.tsx +35 -35
- package/workspace/client/src/components/Layout/Footer.tsx +12 -12
- package/workspace/client/src/components/Layout/MobileNav.tsx +23 -23
- package/workspace/client/src/components/Layout/Sidebar.tsx +81 -81
- package/workspace/client/src/components/ui/avatar.tsx +85 -85
- package/workspace/client/src/components/ui/badge.tsx +40 -41
- package/workspace/client/src/components/ui/button.tsx +56 -57
- package/workspace/client/src/components/ui/card.tsx +75 -75
- package/workspace/client/src/components/ui/dialog.tsx +114 -117
- package/workspace/client/src/components/ui/dropdown-menu.tsx +191 -199
- package/workspace/client/src/components/ui/input.tsx +17 -17
- package/workspace/client/src/components/ui/scroll-area.tsx +47 -47
- package/workspace/client/src/components/ui/select.tsx +145 -151
- package/workspace/client/src/components/ui/separator.tsx +21 -21
- package/workspace/client/src/components/ui/sheet.tsx +102 -102
- package/workspace/client/src/components/ui/skeleton.tsx +10 -10
- package/workspace/client/src/components/ui/switch.tsx +26 -26
- package/workspace/client/src/components/ui/tabs.tsx +69 -69
- package/workspace/client/src/components/ui/textarea.tsx +14 -14
- package/workspace/client/src/components/ui/tooltip.tsx +37 -37
- package/workspace/client/src/lib/utils.ts +1 -1
- package/workspace/client/src/main.tsx +3 -3
- package/workspace/client/src/styles/globals.css +76 -85
- package/workspace/skills/code-reviewer/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/code-reviewer/skills/code-reviewer/SKILL.md +0 -6
- package/workspace/skills/daily-standup/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/daily-standup/skills/daily-standup/SKILL.md +0 -7
- package/workspace/skills/workspace-helper/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/workspace-helper/skills/workspace-helper/SKILL.md +0 -2
- package/bin/cli.backup.js +0 -2138
- package/cli/commands/daemon.ts +0 -42
- package/cli/commands/init.ts +0 -32
- package/cli/commands/start.ts +0 -113
- package/cli/commands/tunnel.temp.ts +0 -206
- package/cli/commands/tunnel.ts +0 -227
- package/cli/commands/update.ts +0 -163
- package/cli/core/base-adapter.ts +0 -156
- package/cli/core/cloudflared.ts +0 -113
- package/cli/core/config.ts +0 -73
- package/cli/core/os-detector.ts +0 -43
- package/cli/core/server.ts +0 -109
- package/cli/core/types.ts +0 -15
- package/cli/index.ts +0 -72
- package/cli/platforms/darwin.ts +0 -146
- package/cli/platforms/index.ts +0 -21
- package/cli/platforms/linux.ts +0 -156
- package/cli/platforms/win32.ts +0 -34
- package/cli/utils/ui.ts +0 -37
package/worker/index.ts
CHANGED
|
@@ -5,131 +5,66 @@ import path from 'path';
|
|
|
5
5
|
import { loadConfig, saveConfig } from '../shared/config.js';
|
|
6
6
|
import { paths, WORKSPACE_DIR } from '../shared/paths.js';
|
|
7
7
|
import { log } from '../shared/logger.js';
|
|
8
|
-
import {
|
|
9
|
-
initDb,
|
|
10
|
-
closeDb,
|
|
11
|
-
listConversations,
|
|
12
|
-
createConversation,
|
|
13
|
-
deleteConversation,
|
|
14
|
-
getMessages,
|
|
15
|
-
addMessage,
|
|
16
|
-
getSetting,
|
|
17
|
-
getAllSettings,
|
|
18
|
-
setSetting,
|
|
19
|
-
createSession,
|
|
20
|
-
getSession,
|
|
21
|
-
deleteExpiredSessions,
|
|
22
|
-
getRecentMessages,
|
|
23
|
-
getMessagesBefore,
|
|
24
|
-
addPushSubscription,
|
|
25
|
-
removePushSubscription,
|
|
26
|
-
getAllPushSubscriptions,
|
|
27
|
-
getPushSubscriptionByEndpoint,
|
|
28
|
-
createTrustedDevice,
|
|
29
|
-
getTrustedDevice,
|
|
30
|
-
updateDeviceLastSeen,
|
|
31
|
-
listTrustedDevices,
|
|
32
|
-
deleteTrustedDevice,
|
|
33
|
-
deleteExpiredDevices,
|
|
34
|
-
deleteAllTrustedDevices
|
|
35
|
-
} from './db.js';
|
|
8
|
+
import { initDb, closeDb, listConversations, createConversation, deleteConversation, getMessages, addMessage, getSetting, getAllSettings, setSetting, createSession, getSession, deleteExpiredSessions, getRecentMessages, getMessagesBefore, addPushSubscription, removePushSubscription, getAllPushSubscriptions, getPushSubscriptionByEndpoint, createTrustedDevice, getTrustedDevice, updateDeviceLastSeen, listTrustedDevices, deleteTrustedDevice, deleteExpiredDevices, deleteAllTrustedDevices } from './db.js';
|
|
36
9
|
import webpush from 'web-push';
|
|
37
10
|
import { TOTP } from 'otpauth';
|
|
38
11
|
import QRCode from 'qrcode';
|
|
39
|
-
import {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
getCodexAuthStatus,
|
|
43
|
-
readCodexAccessToken
|
|
44
|
-
} from './codex-auth.js';
|
|
45
|
-
import {
|
|
46
|
-
startClaudeOAuth,
|
|
47
|
-
exchangeClaudeCode,
|
|
48
|
-
getClaudeAuthStatus,
|
|
49
|
-
readClaudeAccessToken
|
|
50
|
-
} from './claude-auth.js';
|
|
51
|
-
import {
|
|
52
|
-
checkAvailability,
|
|
53
|
-
registerHandle,
|
|
54
|
-
claimReservedHandle,
|
|
55
|
-
releaseHandle,
|
|
56
|
-
updateTunnelUrl,
|
|
57
|
-
startHeartbeat,
|
|
58
|
-
stopHeartbeat
|
|
59
|
-
} from '../shared/relay.js';
|
|
12
|
+
import { startCodexOAuth, cancelCodexOAuth, getCodexAuthStatus, readCodexAccessToken } from './codex-auth.js';
|
|
13
|
+
import { startClaudeOAuth, exchangeClaudeCode, getClaudeAuthStatus, readClaudeAccessToken } from './claude-auth.js';
|
|
14
|
+
import { checkAvailability, registerHandle, claimReservedHandle, releaseHandle, updateTunnelUrl, startHeartbeat, stopHeartbeat } from '../shared/relay.js';
|
|
60
15
|
import { ensureFileDirs } from '../supervisor/file-saver.js';
|
|
61
16
|
|
|
62
17
|
// ── Password hashing (scrypt) ──
|
|
63
18
|
|
|
64
19
|
function hashPassword(password: string): string {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
20
|
+
const salt = crypto.randomBytes(16).toString('hex');
|
|
21
|
+
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
|
|
22
|
+
return `${salt}:${hash}`;
|
|
68
23
|
}
|
|
69
24
|
|
|
70
25
|
function verifyPassword(password: string, stored: string): boolean {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
26
|
+
const [salt, hash] = stored.split(':');
|
|
27
|
+
const test = crypto.scryptSync(password, salt, 64).toString('hex');
|
|
28
|
+
return hash === test;
|
|
74
29
|
}
|
|
75
30
|
|
|
76
31
|
// ── TOTP helpers ──
|
|
77
32
|
|
|
78
33
|
function generateTOTPSecret(): string {
|
|
79
|
-
|
|
80
|
-
.randomBytes(20)
|
|
81
|
-
.toString('base64url')
|
|
82
|
-
.replace(/[^A-Z2-7]/gi, '')
|
|
83
|
-
.slice(0, 32)
|
|
84
|
-
.toUpperCase();
|
|
34
|
+
return crypto.randomBytes(20).toString('base64url').replace(/[^A-Z2-7]/gi, '').slice(0, 32).toUpperCase();
|
|
85
35
|
}
|
|
86
36
|
|
|
87
37
|
function verifyTOTPCode(code: string, secret: string): boolean {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
digits: 6,
|
|
92
|
-
period: 30,
|
|
93
|
-
secret
|
|
94
|
-
});
|
|
95
|
-
const delta = totp.validate({ token: code, window: 1 });
|
|
96
|
-
return delta !== null;
|
|
38
|
+
const totp = new TOTP({ issuer: 'Fluxy', algorithm: 'SHA1', digits: 6, period: 30, secret });
|
|
39
|
+
const delta = totp.validate({ token: code, window: 1 });
|
|
40
|
+
return delta !== null;
|
|
97
41
|
}
|
|
98
42
|
|
|
99
43
|
function generateRecoveryCodes(): string[] {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
44
|
+
const codes: string[] = [];
|
|
45
|
+
for (let i = 0; i < 8; i++) {
|
|
46
|
+
codes.push(crypto.randomBytes(4).toString('hex'));
|
|
47
|
+
}
|
|
48
|
+
return codes;
|
|
105
49
|
}
|
|
106
50
|
|
|
107
51
|
function hashRecoveryCode(code: string): string {
|
|
108
|
-
|
|
52
|
+
return crypto.createHash('sha256').update(code.toLowerCase()).digest('hex');
|
|
109
53
|
}
|
|
110
54
|
|
|
111
|
-
function verifyRecoveryCode(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const remaining = [...hashes];
|
|
119
|
-
remaining.splice(idx, 1);
|
|
120
|
-
return { valid: true, remaining };
|
|
55
|
+
function verifyRecoveryCode(code: string, hashes: string[]): { valid: boolean; remaining: string[] } {
|
|
56
|
+
const h = hashRecoveryCode(code);
|
|
57
|
+
const idx = hashes.indexOf(h);
|
|
58
|
+
if (idx === -1) return { valid: false, remaining: hashes };
|
|
59
|
+
const remaining = [...hashes];
|
|
60
|
+
remaining.splice(idx, 1);
|
|
61
|
+
return { valid: true, remaining };
|
|
121
62
|
}
|
|
122
63
|
|
|
123
|
-
function parseCookie(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
):
|
|
127
|
-
if (!cookieHeader) return undefined;
|
|
128
|
-
const match = cookieHeader
|
|
129
|
-
.split(';')
|
|
130
|
-
.map(c => c.trim())
|
|
131
|
-
.find(c => c.startsWith(`${name}=`));
|
|
132
|
-
return match ? match.slice(name.length + 1) : undefined;
|
|
64
|
+
function parseCookie(cookieHeader: string | undefined, name: string): string | undefined {
|
|
65
|
+
if (!cookieHeader) return undefined;
|
|
66
|
+
const match = cookieHeader.split(';').map(c => c.trim()).find(c => c.startsWith(`${name}=`));
|
|
67
|
+
return match ? match.slice(name.length + 1) : undefined;
|
|
133
68
|
}
|
|
134
69
|
|
|
135
70
|
const port = parseInt(process.env.WORKER_PORT || '3001', 10);
|
|
@@ -144,23 +79,23 @@ ensureFileDirs();
|
|
|
144
79
|
// ── VAPID key management (Web Push) ──
|
|
145
80
|
|
|
146
81
|
function getOrCreateVapidKeys() {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
82
|
+
let publicKey = getSetting('vapid_public_key');
|
|
83
|
+
let privateKey = getSetting('vapid_private_key');
|
|
84
|
+
if (!publicKey || !privateKey) {
|
|
85
|
+
const keys = webpush.generateVAPIDKeys();
|
|
86
|
+
publicKey = keys.publicKey;
|
|
87
|
+
privateKey = keys.privateKey;
|
|
88
|
+
setSetting('vapid_public_key', publicKey);
|
|
89
|
+
setSetting('vapid_private_key', privateKey);
|
|
90
|
+
log.ok('Generated new VAPID keys');
|
|
91
|
+
}
|
|
92
|
+
return { publicKey, privateKey };
|
|
158
93
|
}
|
|
159
94
|
|
|
160
95
|
function initWebPush() {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
96
|
+
const { publicKey, privateKey } = getOrCreateVapidKeys();
|
|
97
|
+
webpush.setVapidDetails('mailto:push@fluxy.bot', publicKey, privateKey);
|
|
98
|
+
log.ok('Web Push initialized');
|
|
164
99
|
}
|
|
165
100
|
|
|
166
101
|
initWebPush();
|
|
@@ -171,914 +106,742 @@ app.use(express.json({ limit: '10mb' }));
|
|
|
171
106
|
|
|
172
107
|
// Prevent browsers/CDN/relay from caching API responses (avoids stale 502s)
|
|
173
108
|
app.use('/api', (_, res, next) => {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
res.set('Expires', '0');
|
|
180
|
-
res.set('Surrogate-Control', 'no-store');
|
|
181
|
-
next();
|
|
109
|
+
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
|
110
|
+
res.set('Pragma', 'no-cache');
|
|
111
|
+
res.set('Expires', '0');
|
|
112
|
+
res.set('Surrogate-Control', 'no-store');
|
|
113
|
+
next();
|
|
182
114
|
});
|
|
183
115
|
|
|
184
116
|
app.get('/api/health', (_, res) => res.json({ status: 'ok' }));
|
|
185
117
|
app.get('/api/conversations', (_, res) => res.json(listConversations()));
|
|
186
118
|
app.get('/api/conversations/:id', (req, res) => {
|
|
187
|
-
|
|
188
|
-
|
|
119
|
+
const msgs = getMessages(req.params.id);
|
|
120
|
+
res.json({ id: req.params.id, messages: msgs });
|
|
189
121
|
});
|
|
190
122
|
app.post('/api/conversations', (req, res) => {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
123
|
+
const { title, model } = req.body || {};
|
|
124
|
+
const conv = createConversation(title, model);
|
|
125
|
+
res.json(conv);
|
|
194
126
|
});
|
|
195
127
|
app.post('/api/conversations/:id/messages', (req, res) => {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
const msg = addMessage(req.params.id, role, content, meta);
|
|
202
|
-
res.json(msg);
|
|
128
|
+
const { role, content, meta } = req.body || {};
|
|
129
|
+
if (!role || !content) { res.status(400).json({ error: 'Missing role or content' }); return; }
|
|
130
|
+
const msg = addMessage(req.params.id, role, content, meta);
|
|
131
|
+
res.json(msg);
|
|
203
132
|
});
|
|
204
133
|
app.get('/api/conversations/:id/messages/recent', (req, res) => {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
134
|
+
const limit = parseInt(req.query.limit as string) || 20;
|
|
135
|
+
const msgs = getRecentMessages(req.params.id, Math.min(limit, 100));
|
|
136
|
+
res.json(msgs);
|
|
208
137
|
});
|
|
209
138
|
app.get('/api/conversations/:id/messages', (req, res) => {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
});
|
|
218
|
-
app.delete('/api/conversations/:id', (req, res) => {
|
|
219
|
-
deleteConversation(req.params.id);
|
|
220
|
-
res.json({ ok: true });
|
|
221
|
-
});
|
|
139
|
+
const before = req.query.before as string;
|
|
140
|
+
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
|
141
|
+
if (before) {
|
|
142
|
+
res.json(getMessagesBefore(req.params.id, before, limit));
|
|
143
|
+
} else {
|
|
144
|
+
res.json(getRecentMessages(req.params.id, limit));
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
app.delete('/api/conversations/:id', (req, res) => { deleteConversation(req.params.id); res.json({ ok: true }); });
|
|
222
148
|
app.get('/api/settings', (_, res) => res.json(getAllSettings()));
|
|
223
149
|
app.put('/api/settings/:key', (req, res) => {
|
|
224
|
-
|
|
225
|
-
|
|
150
|
+
setSetting(req.params.key, req.body.value);
|
|
151
|
+
res.json({ ok: true });
|
|
226
152
|
});
|
|
227
153
|
|
|
228
154
|
// ── Current conversation (shared across devices) ──
|
|
229
155
|
|
|
230
156
|
app.get('/api/context/current', (_, res) => {
|
|
231
|
-
|
|
232
|
-
|
|
157
|
+
const convId = getSetting('current_conversation');
|
|
158
|
+
res.json({ conversationId: convId || null });
|
|
233
159
|
});
|
|
234
160
|
|
|
235
161
|
app.post('/api/context/set', (req, res) => {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
162
|
+
const { conversationId } = req.body;
|
|
163
|
+
if (conversationId) {
|
|
164
|
+
setSetting('current_conversation', conversationId);
|
|
165
|
+
}
|
|
166
|
+
res.json({ ok: true });
|
|
241
167
|
});
|
|
242
168
|
|
|
243
169
|
app.post('/api/context/clear', (_, res) => {
|
|
244
|
-
|
|
245
|
-
|
|
170
|
+
setSetting('current_conversation', '');
|
|
171
|
+
res.json({ ok: true });
|
|
246
172
|
});
|
|
247
173
|
|
|
248
174
|
// ── Codex OAuth routes ──
|
|
249
175
|
|
|
250
176
|
app.post('/api/auth/codex/start', async (_req, res) => {
|
|
251
|
-
|
|
252
|
-
|
|
177
|
+
const result = await startCodexOAuth();
|
|
178
|
+
res.json(result);
|
|
253
179
|
});
|
|
254
180
|
|
|
255
181
|
app.post('/api/auth/codex/cancel', (_req, res) => {
|
|
256
|
-
|
|
257
|
-
|
|
182
|
+
cancelCodexOAuth();
|
|
183
|
+
res.json({ ok: true });
|
|
258
184
|
});
|
|
259
185
|
|
|
260
186
|
app.get('/api/auth/codex/status', (_req, res) => {
|
|
261
|
-
|
|
187
|
+
res.json(getCodexAuthStatus());
|
|
262
188
|
});
|
|
263
189
|
|
|
264
190
|
// ── Claude OAuth routes ──
|
|
265
191
|
|
|
266
192
|
app.post('/api/auth/claude/start', (_req, res) => {
|
|
267
|
-
|
|
193
|
+
res.json(startClaudeOAuth());
|
|
268
194
|
});
|
|
269
195
|
|
|
270
196
|
app.post('/api/auth/claude/exchange', async (req, res) => {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
const result = await exchangeClaudeCode(code);
|
|
277
|
-
res.json(result);
|
|
197
|
+
const { code } = req.body;
|
|
198
|
+
if (!code) { res.json({ success: false, error: 'No code provided' }); return; }
|
|
199
|
+
const result = await exchangeClaudeCode(code);
|
|
200
|
+
res.json(result);
|
|
278
201
|
});
|
|
279
202
|
|
|
280
203
|
app.get('/api/auth/claude/status', async (_req, res) => {
|
|
281
|
-
|
|
204
|
+
res.json(await getClaudeAuthStatus());
|
|
282
205
|
});
|
|
283
206
|
|
|
284
207
|
// ── Handle registration ──
|
|
285
208
|
|
|
286
209
|
app.get('/api/handle/check/:username', async (req, res) => {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
valid: false,
|
|
294
|
-
error: 'Could not reach relay server'
|
|
295
|
-
});
|
|
296
|
-
}
|
|
210
|
+
try {
|
|
211
|
+
const result = await checkAvailability(req.params.username);
|
|
212
|
+
res.json(result);
|
|
213
|
+
} catch {
|
|
214
|
+
res.json({ available: false, valid: false, error: 'Could not reach relay server' });
|
|
215
|
+
}
|
|
297
216
|
});
|
|
298
217
|
|
|
299
218
|
app.get('/api/handle/status', (_req, res) => {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
219
|
+
const cfg = loadConfig();
|
|
220
|
+
res.json({
|
|
221
|
+
registered: !!cfg.relay?.token,
|
|
222
|
+
username: cfg.username || '',
|
|
223
|
+
tier: cfg.relay?.tier || '',
|
|
224
|
+
url: cfg.relay?.url || '',
|
|
225
|
+
});
|
|
307
226
|
});
|
|
308
227
|
|
|
309
228
|
app.post('/api/handle/register', async (req, res) => {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
229
|
+
const { username, tier } = req.body;
|
|
230
|
+
log.ok(`Handle register request: username=${username}, tier=${tier}`);
|
|
231
|
+
if (!username || !tier) {
|
|
232
|
+
res.status(400).json({ error: 'Missing username or tier' });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const result = await registerHandle(username, tier);
|
|
238
|
+
log.ok(`Handle registered with relay: url=${result.relayUrl}, token=${result.token ? 'received' : 'MISSING'}`);
|
|
239
|
+
|
|
240
|
+
// Save to config
|
|
241
|
+
const cfg = loadConfig();
|
|
242
|
+
cfg.username = username;
|
|
243
|
+
cfg.relay = { token: result.token, tier, url: result.relayUrl };
|
|
244
|
+
saveConfig(cfg);
|
|
322
245
|
|
|
323
|
-
|
|
324
|
-
const cfg = loadConfig();
|
|
325
|
-
cfg.username = username;
|
|
326
|
-
cfg.relay = { token: result.token, tier, url: result.relayUrl };
|
|
327
|
-
saveConfig(cfg);
|
|
328
|
-
|
|
329
|
-
log.ok(`Handle registered: ${result.relayUrl}`);
|
|
330
|
-
|
|
331
|
-
// If the tunnel is already running, push the URL to the relay immediately
|
|
332
|
-
if (cfg.tunnelUrl) {
|
|
333
|
-
try {
|
|
334
|
-
await updateTunnelUrl(result.token, cfg.tunnelUrl);
|
|
335
|
-
startHeartbeat(result.token, cfg.tunnelUrl);
|
|
336
|
-
} catch (err: any) {
|
|
337
|
-
log.warn(`Relay update: ${err.message}`);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
246
|
+
log.ok(`Handle registered: ${result.relayUrl}`);
|
|
340
247
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
248
|
+
// If the tunnel is already running, push the URL to the relay immediately
|
|
249
|
+
if (cfg.tunnelUrl) {
|
|
250
|
+
try {
|
|
251
|
+
await updateTunnelUrl(result.token, cfg.tunnelUrl);
|
|
252
|
+
startHeartbeat(result.token, cfg.tunnelUrl);
|
|
253
|
+
} catch (err: any) {
|
|
254
|
+
log.warn(`Relay update: ${err.message}`);
|
|
255
|
+
}
|
|
344
256
|
}
|
|
257
|
+
|
|
258
|
+
res.json({ ok: true, url: result.relayUrl });
|
|
259
|
+
} catch (err: any) {
|
|
260
|
+
res.status(400).json({ error: err.message || 'Registration failed' });
|
|
261
|
+
}
|
|
345
262
|
});
|
|
346
263
|
|
|
347
264
|
app.post('/api/handle/change', async (req, res) => {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
265
|
+
const { username, tier } = req.body;
|
|
266
|
+
log.ok(`Handle change request: new=${username}, tier=${tier}`);
|
|
267
|
+
if (!username || !tier) {
|
|
268
|
+
res.status(400).json({ error: 'Missing username or tier' });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const cfg = loadConfig();
|
|
274
|
+
log.ok(`Handle change: old username=${cfg.username || '(none)'}, old token=${cfg.relay?.token ? 'yes' : 'NO — cannot release!'}`);
|
|
275
|
+
|
|
276
|
+
// Release old handle if one exists
|
|
277
|
+
if (cfg.relay?.token) {
|
|
278
|
+
stopHeartbeat();
|
|
279
|
+
try {
|
|
280
|
+
await releaseHandle(cfg.relay.token);
|
|
281
|
+
log.ok(`Released old handle: ${cfg.username}`);
|
|
282
|
+
} catch (err: any) {
|
|
283
|
+
log.warn(`Release old handle: ${err.message}`);
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
log.warn('Handle change: no existing token found — old handle will NOT be released (orphaned in MongoDB)');
|
|
353
287
|
}
|
|
354
288
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
log.ok(
|
|
358
|
-
`Handle change: old username=${cfg.username || '(none)'}, old token=${cfg.relay?.token ? 'yes' : 'NO — cannot release!'}`
|
|
359
|
-
);
|
|
360
|
-
|
|
361
|
-
// Release old handle if one exists
|
|
362
|
-
if (cfg.relay?.token) {
|
|
363
|
-
stopHeartbeat();
|
|
364
|
-
try {
|
|
365
|
-
await releaseHandle(cfg.relay.token);
|
|
366
|
-
log.ok(`Released old handle: ${cfg.username}`);
|
|
367
|
-
} catch (err: any) {
|
|
368
|
-
log.warn(`Release old handle: ${err.message}`);
|
|
369
|
-
}
|
|
370
|
-
} else {
|
|
371
|
-
log.warn(
|
|
372
|
-
'Handle change: no existing token found — old handle will NOT be released (orphaned in MongoDB)'
|
|
373
|
-
);
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// Register new handle
|
|
377
|
-
const result = await registerHandle(username, tier);
|
|
289
|
+
// Register new handle
|
|
290
|
+
const result = await registerHandle(username, tier);
|
|
378
291
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
292
|
+
cfg.username = username;
|
|
293
|
+
cfg.relay = { token: result.token, tier, url: result.relayUrl };
|
|
294
|
+
saveConfig(cfg);
|
|
382
295
|
|
|
383
|
-
|
|
296
|
+
log.ok(`Handle changed to: ${result.relayUrl}`);
|
|
384
297
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
res.json({ ok: true, url: result.relayUrl });
|
|
396
|
-
} catch (err: any) {
|
|
397
|
-
res.status(400).json({ error: err.message || 'Handle change failed' });
|
|
298
|
+
// Push tunnel URL to new handle
|
|
299
|
+
if (cfg.tunnelUrl) {
|
|
300
|
+
try {
|
|
301
|
+
await updateTunnelUrl(result.token, cfg.tunnelUrl);
|
|
302
|
+
startHeartbeat(result.token, cfg.tunnelUrl);
|
|
303
|
+
} catch (err: any) {
|
|
304
|
+
log.warn(`Relay update: ${err.message}`);
|
|
305
|
+
}
|
|
398
306
|
}
|
|
307
|
+
|
|
308
|
+
res.json({ ok: true, url: result.relayUrl });
|
|
309
|
+
} catch (err: any) {
|
|
310
|
+
res.status(400).json({ error: err.message || 'Handle change failed' });
|
|
311
|
+
}
|
|
399
312
|
});
|
|
400
313
|
|
|
401
314
|
app.post('/api/handle/claim-reserved', async (req, res) => {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
315
|
+
const { handle, hash } = req.body;
|
|
316
|
+
if (!handle || !hash) {
|
|
317
|
+
res.status(400).json({ error: 'Missing handle or activation code' });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
407
320
|
|
|
408
|
-
|
|
409
|
-
|
|
321
|
+
try {
|
|
322
|
+
const result = await claimReservedHandle(handle, hash);
|
|
410
323
|
|
|
411
|
-
|
|
412
|
-
|
|
324
|
+
// Save to config (same as normal registration)
|
|
325
|
+
const cfg = loadConfig();
|
|
413
326
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
327
|
+
// Release old handle if one exists
|
|
328
|
+
if (cfg.relay?.token) {
|
|
329
|
+
stopHeartbeat();
|
|
330
|
+
try {
|
|
331
|
+
await releaseHandle(cfg.relay.token);
|
|
332
|
+
} catch (err: any) {
|
|
333
|
+
log.warn(`Release old handle: ${err.message}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
423
336
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
tier: 'premium',
|
|
428
|
-
url: result.relayUrl
|
|
429
|
-
};
|
|
430
|
-
saveConfig(cfg);
|
|
431
|
-
|
|
432
|
-
log.ok(`Reserved handle claimed: ${result.relayUrl}`);
|
|
433
|
-
|
|
434
|
-
// Push tunnel URL if running
|
|
435
|
-
if (cfg.tunnelUrl) {
|
|
436
|
-
try {
|
|
437
|
-
await updateTunnelUrl(result.token, cfg.tunnelUrl);
|
|
438
|
-
startHeartbeat(result.token, cfg.tunnelUrl);
|
|
439
|
-
} catch (err: any) {
|
|
440
|
-
log.warn(`Relay update: ${err.message}`);
|
|
441
|
-
}
|
|
442
|
-
}
|
|
337
|
+
cfg.username = handle;
|
|
338
|
+
cfg.relay = { token: result.token, tier: 'premium', url: result.relayUrl };
|
|
339
|
+
saveConfig(cfg);
|
|
443
340
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
341
|
+
log.ok(`Reserved handle claimed: ${result.relayUrl}`);
|
|
342
|
+
|
|
343
|
+
// Push tunnel URL if running
|
|
344
|
+
if (cfg.tunnelUrl) {
|
|
345
|
+
try {
|
|
346
|
+
await updateTunnelUrl(result.token, cfg.tunnelUrl);
|
|
347
|
+
startHeartbeat(result.token, cfg.tunnelUrl);
|
|
348
|
+
} catch (err: any) {
|
|
349
|
+
log.warn(`Relay update: ${err.message}`);
|
|
350
|
+
}
|
|
447
351
|
}
|
|
352
|
+
|
|
353
|
+
res.json({ ok: true, url: result.relayUrl });
|
|
354
|
+
} catch (err: any) {
|
|
355
|
+
res.status(400).json({ error: err.message || 'Claim failed' });
|
|
356
|
+
}
|
|
448
357
|
});
|
|
449
358
|
|
|
450
359
|
// ── Onboarding ──
|
|
451
360
|
|
|
452
361
|
app.get('/api/onboard/status', (_, res) => {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
totpEnabled: settings.totp_enabled === 'true'
|
|
476
|
-
});
|
|
362
|
+
const settings = getAllSettings();
|
|
363
|
+
const cfg = loadConfig();
|
|
364
|
+
const hasToken = !!cfg.relay?.token;
|
|
365
|
+
res.json({
|
|
366
|
+
userName: settings.user_name || '',
|
|
367
|
+
agentName: settings.agent_name || '',
|
|
368
|
+
portalUser: settings.portal_user || '',
|
|
369
|
+
portalConfigured: !!settings.portal_pass,
|
|
370
|
+
whisperEnabled: settings.whisper_enabled === 'true',
|
|
371
|
+
whisperKey: settings.whisper_key || '',
|
|
372
|
+
provider: cfg.ai?.provider || '',
|
|
373
|
+
model: cfg.ai?.model || '',
|
|
374
|
+
handle: hasToken ? {
|
|
375
|
+
username: cfg.username,
|
|
376
|
+
tier: cfg.relay.tier,
|
|
377
|
+
url: cfg.relay.url,
|
|
378
|
+
} : null,
|
|
379
|
+
tunnelMode: cfg.tunnel?.mode || 'quick',
|
|
380
|
+
tunnelDomain: cfg.tunnel?.domain || '',
|
|
381
|
+
tunnelUrl: cfg.tunnelUrl || '',
|
|
382
|
+
totpEnabled: settings.totp_enabled === 'true',
|
|
383
|
+
});
|
|
477
384
|
});
|
|
478
385
|
|
|
479
386
|
app.post('/api/portal/verify-password', (req, res) => {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
res.json({ valid: verifyPassword(password, stored) });
|
|
387
|
+
const { password } = req.body;
|
|
388
|
+
const stored = getSetting('portal_pass');
|
|
389
|
+
if (!stored) { res.json({ valid: false, error: 'No password set' }); return; }
|
|
390
|
+
res.json({ valid: verifyPassword(password, stored) });
|
|
487
391
|
});
|
|
488
392
|
|
|
489
393
|
// Shared login logic (used by both POST and GET handlers)
|
|
490
|
-
function handleLogin(
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
const
|
|
501
|
-
if (
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const totpEnabled = getSetting('totp_enabled') === 'true';
|
|
512
|
-
if (totpEnabled) {
|
|
513
|
-
// Check for trusted device cookie
|
|
514
|
-
const deviceToken = parseCookie(req.headers.cookie, 'fluxy_device');
|
|
515
|
-
if (deviceToken) {
|
|
516
|
-
const device = getTrustedDevice(deviceToken);
|
|
517
|
-
if (device) {
|
|
518
|
-
updateDeviceLastSeen(deviceToken);
|
|
519
|
-
// Trusted device — skip TOTP
|
|
520
|
-
deleteExpiredSessions();
|
|
521
|
-
const token = crypto.randomBytes(64).toString('hex');
|
|
522
|
-
const expiresAt = new Date(
|
|
523
|
-
Date.now() + 7 * 24 * 60 * 60 * 1000
|
|
524
|
-
).toISOString();
|
|
525
|
-
createSession(token, expiresAt);
|
|
526
|
-
res.json({ token, expiresAt });
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
// No valid trusted device — require TOTP
|
|
531
|
-
const pendingToken = crypto.randomBytes(32).toString('hex');
|
|
532
|
-
const pendingExpiry = new Date(
|
|
533
|
-
Date.now() + 5 * 60 * 1000
|
|
534
|
-
).toISOString();
|
|
535
|
-
setSetting(`totp_pending_login:${pendingToken}`, pendingExpiry);
|
|
536
|
-
res.json({ requiresTOTP: true, pendingToken });
|
|
394
|
+
function handleLogin(username: string | undefined, password: string | undefined, req: any, res: any) {
|
|
395
|
+
if (!password) { res.status(400).json({ error: 'Password required' }); return; }
|
|
396
|
+
const storedPass = getSetting('portal_pass');
|
|
397
|
+
if (!storedPass) { res.status(400).json({ error: 'No password set' }); return; }
|
|
398
|
+
if (!verifyPassword(password, storedPass)) { res.status(401).json({ error: 'Invalid password' }); return; }
|
|
399
|
+
|
|
400
|
+
// Check if TOTP is enabled
|
|
401
|
+
const totpEnabled = getSetting('totp_enabled') === 'true';
|
|
402
|
+
if (totpEnabled) {
|
|
403
|
+
// Check for trusted device cookie
|
|
404
|
+
const deviceToken = parseCookie(req.headers.cookie, 'fluxy_device');
|
|
405
|
+
if (deviceToken) {
|
|
406
|
+
const device = getTrustedDevice(deviceToken);
|
|
407
|
+
if (device) {
|
|
408
|
+
updateDeviceLastSeen(deviceToken);
|
|
409
|
+
// Trusted device — skip TOTP
|
|
410
|
+
deleteExpiredSessions();
|
|
411
|
+
const token = crypto.randomBytes(64).toString('hex');
|
|
412
|
+
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
413
|
+
createSession(token, expiresAt);
|
|
414
|
+
res.json({ token, expiresAt });
|
|
537
415
|
return;
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
const
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// No valid trusted device — require TOTP
|
|
419
|
+
const pendingToken = crypto.randomBytes(32).toString('hex');
|
|
420
|
+
const pendingExpiry = new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
|
421
|
+
setSetting(`totp_pending_login:${pendingToken}`, pendingExpiry);
|
|
422
|
+
res.json({ requiresTOTP: true, pendingToken });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
deleteExpiredSessions();
|
|
427
|
+
const token = crypto.randomBytes(64).toString('hex');
|
|
428
|
+
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
429
|
+
createSession(token, expiresAt);
|
|
430
|
+
res.json({ token, expiresAt });
|
|
547
431
|
}
|
|
548
432
|
|
|
549
433
|
// POST: credentials in JSON body
|
|
550
434
|
app.post('/api/portal/login', (req, res) => {
|
|
551
|
-
|
|
552
|
-
|
|
435
|
+
const { username, password } = req.body;
|
|
436
|
+
handleLogin(username, password, req, res);
|
|
553
437
|
});
|
|
554
438
|
|
|
555
439
|
// GET: credentials via Authorization Basic header (relay proxies don't forward POST bodies)
|
|
556
440
|
app.get('/api/portal/login', (req, res) => {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const sep = decoded.indexOf(':');
|
|
564
|
-
if (sep < 0) {
|
|
565
|
-
res.status(400).json({ error: 'Invalid credentials format' });
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
handleLogin(decoded.slice(0, sep), decoded.slice(sep + 1), req, res);
|
|
441
|
+
const authHeader = req.headers['authorization'];
|
|
442
|
+
if (!authHeader?.startsWith('Basic ')) { res.status(400).json({ error: 'Authorization header required' }); return; }
|
|
443
|
+
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
|
|
444
|
+
const sep = decoded.indexOf(':');
|
|
445
|
+
if (sep < 0) { res.status(400).json({ error: 'Invalid credentials format' }); return; }
|
|
446
|
+
handleLogin(decoded.slice(0, sep), decoded.slice(sep + 1), req, res);
|
|
569
447
|
});
|
|
570
448
|
|
|
571
449
|
// POST + GET for validate-token (same relay issue)
|
|
572
450
|
function handleValidateToken(token: string | undefined, res: any) {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
}
|
|
577
|
-
const session = getSession(token);
|
|
578
|
-
res.json({ valid: !!session });
|
|
451
|
+
if (!token) { res.json({ valid: false }); return; }
|
|
452
|
+
const session = getSession(token);
|
|
453
|
+
res.json({ valid: !!session });
|
|
579
454
|
}
|
|
580
455
|
|
|
581
456
|
app.post('/api/portal/validate-token', (req, res) => {
|
|
582
|
-
|
|
457
|
+
handleValidateToken(req.body.token, res);
|
|
583
458
|
});
|
|
584
459
|
|
|
585
460
|
app.get('/api/portal/validate-token', (req, res) => {
|
|
586
|
-
|
|
461
|
+
handleValidateToken(req.query.token as string, res);
|
|
587
462
|
});
|
|
588
463
|
|
|
589
464
|
// ── TOTP 2FA endpoints ──
|
|
590
465
|
|
|
591
466
|
app.get('/api/portal/totp/status', (_req, res) => {
|
|
592
|
-
|
|
467
|
+
res.json({ enabled: getSetting('totp_enabled') === 'true' });
|
|
593
468
|
});
|
|
594
469
|
|
|
595
470
|
app.post('/api/portal/totp/setup', async (req, res) => {
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
if (!authorized) {
|
|
611
|
-
res.status(401).json({ error: 'Unauthorized' });
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
471
|
+
// Verify caller has auth: session token, correct password, or initial onboard (no password set yet)
|
|
472
|
+
const authHeader = req.headers['authorization'];
|
|
473
|
+
let authorized = false;
|
|
474
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
475
|
+
const session = getSession(authHeader.slice(7));
|
|
476
|
+
if (session) authorized = true;
|
|
477
|
+
}
|
|
478
|
+
if (!authorized && req.body?.password) {
|
|
479
|
+
const storedPass = getSetting('portal_pass');
|
|
480
|
+
if (storedPass && verifyPassword(req.body.password, storedPass)) authorized = true;
|
|
481
|
+
}
|
|
482
|
+
// During initial onboard, no password is stored yet — allow setup
|
|
483
|
+
if (!authorized && !getSetting('portal_pass')) authorized = true;
|
|
484
|
+
if (!authorized) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
|
614
485
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
try {
|
|
630
|
-
const qrDataUri = await QRCode.toDataURL(otpauthUri, {
|
|
631
|
-
width: 256,
|
|
632
|
-
margin: 2
|
|
633
|
-
});
|
|
634
|
-
res.json({ secret, qrDataUri, otpauthUri });
|
|
635
|
-
} catch (err: any) {
|
|
636
|
-
res.status(500).json({ error: 'Failed to generate QR code' });
|
|
637
|
-
}
|
|
486
|
+
const secret = generateTOTPSecret();
|
|
487
|
+
setSetting('totp_pending_secret', secret);
|
|
488
|
+
|
|
489
|
+
const botName = getSetting('agent_name') || 'Fluxy';
|
|
490
|
+
const totp = new TOTP({ issuer: 'Fluxy', label: botName, algorithm: 'SHA1', digits: 6, period: 30, secret });
|
|
491
|
+
const otpauthUri = totp.toString();
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
const qrDataUri = await QRCode.toDataURL(otpauthUri, { width: 256, margin: 2 });
|
|
495
|
+
res.json({ secret, qrDataUri, otpauthUri });
|
|
496
|
+
} catch (err: any) {
|
|
497
|
+
res.status(500).json({ error: 'Failed to generate QR code' });
|
|
498
|
+
}
|
|
638
499
|
});
|
|
639
500
|
|
|
640
501
|
app.post('/api/portal/totp/verify-setup', (req, res) => {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
if (!authorized) {
|
|
655
|
-
res.status(401).json({ error: 'Unauthorized' });
|
|
656
|
-
return;
|
|
657
|
-
}
|
|
502
|
+
const authHeader = req.headers['authorization'];
|
|
503
|
+
let authorized = false;
|
|
504
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
505
|
+
const session = getSession(authHeader.slice(7));
|
|
506
|
+
if (session) authorized = true;
|
|
507
|
+
}
|
|
508
|
+
if (!authorized && req.body?.password) {
|
|
509
|
+
const storedPass = getSetting('portal_pass');
|
|
510
|
+
if (storedPass && verifyPassword(req.body.password, storedPass)) authorized = true;
|
|
511
|
+
}
|
|
512
|
+
// During initial onboard, no password is stored yet — allow verify
|
|
513
|
+
if (!authorized && !getSetting('portal_pass')) authorized = true;
|
|
514
|
+
if (!authorized) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
|
658
515
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
res.status(400).json({ error: 'Code required' });
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
516
|
+
const { code } = req.body;
|
|
517
|
+
if (!code) { res.status(400).json({ error: 'Code required' }); return; }
|
|
664
518
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
res.status(400).json({ error: 'No TOTP setup in progress' });
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
519
|
+
const pendingSecret = getSetting('totp_pending_secret');
|
|
520
|
+
if (!pendingSecret) { res.status(400).json({ error: 'No TOTP setup in progress' }); return; }
|
|
670
521
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
return;
|
|
676
|
-
}
|
|
522
|
+
if (!verifyTOTPCode(code, pendingSecret)) {
|
|
523
|
+
res.status(400).json({ error: 'Invalid code. Check your authenticator app and try again.' });
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
677
526
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
527
|
+
// Success: persist TOTP config
|
|
528
|
+
setSetting('totp_secret', pendingSecret);
|
|
529
|
+
setSetting('totp_enabled', 'true');
|
|
681
530
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
531
|
+
// Generate recovery codes
|
|
532
|
+
const codes = generateRecoveryCodes();
|
|
533
|
+
const hashes = codes.map(hashRecoveryCode);
|
|
534
|
+
setSetting('totp_recovery_codes', JSON.stringify(hashes));
|
|
686
535
|
|
|
687
|
-
|
|
688
|
-
|
|
536
|
+
// Clean up pending secret
|
|
537
|
+
setSetting('totp_pending_secret', '');
|
|
689
538
|
|
|
690
|
-
|
|
539
|
+
res.json({ success: true, recoveryCodes: codes });
|
|
691
540
|
});
|
|
692
541
|
|
|
693
542
|
app.post('/api/portal/totp/disable', (req, res) => {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
res.status(400).json({ error: 'Password and TOTP code required' });
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
543
|
+
const { password, code } = req.body;
|
|
544
|
+
if (!password || !code) { res.status(400).json({ error: 'Password and TOTP code required' }); return; }
|
|
699
545
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
546
|
+
const storedPass = getSetting('portal_pass');
|
|
547
|
+
if (!storedPass || !verifyPassword(password, storedPass)) {
|
|
548
|
+
res.status(401).json({ error: 'Invalid password' });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
705
551
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
res.status(400).json({ error: '2FA is not enabled' });
|
|
709
|
-
return;
|
|
710
|
-
}
|
|
552
|
+
const secret = getSetting('totp_secret');
|
|
553
|
+
if (!secret) { res.status(400).json({ error: '2FA is not enabled' }); return; }
|
|
711
554
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
555
|
+
if (!verifyTOTPCode(code, secret)) {
|
|
556
|
+
res.status(400).json({ error: 'Invalid TOTP code' });
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
716
559
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
560
|
+
// Clear all TOTP settings
|
|
561
|
+
setSetting('totp_enabled', 'false');
|
|
562
|
+
setSetting('totp_secret', '');
|
|
563
|
+
setSetting('totp_recovery_codes', '');
|
|
564
|
+
setSetting('totp_pending_secret', '');
|
|
565
|
+
deleteAllTrustedDevices();
|
|
723
566
|
|
|
724
|
-
|
|
567
|
+
res.json({ success: true });
|
|
725
568
|
});
|
|
726
569
|
|
|
727
570
|
app.get('/api/portal/login/totp', (req, res) => {
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
const hashesJson = getSetting('totp_recovery_codes');
|
|
761
|
-
if (hashesJson) {
|
|
762
|
-
try {
|
|
763
|
-
const hashes = JSON.parse(hashesJson) as string[];
|
|
764
|
-
const result = verifyRecoveryCode(code, hashes);
|
|
765
|
-
if (result.valid) {
|
|
766
|
-
valid = true;
|
|
767
|
-
setSetting(
|
|
768
|
-
'totp_recovery_codes',
|
|
769
|
-
JSON.stringify(result.remaining)
|
|
770
|
-
);
|
|
771
|
-
}
|
|
772
|
-
} catch {}
|
|
571
|
+
const pending = req.query.pending as string;
|
|
572
|
+
const code = req.query.code as string;
|
|
573
|
+
const trust = req.query.trust as string;
|
|
574
|
+
|
|
575
|
+
if (!pending || !code) { res.status(400).json({ error: 'Missing pending token or code' }); return; }
|
|
576
|
+
|
|
577
|
+
// Validate pending token
|
|
578
|
+
const expiry = getSetting(`totp_pending_login:${pending}`);
|
|
579
|
+
if (!expiry || new Date(expiry) < new Date()) {
|
|
580
|
+
res.status(401).json({ error: 'Login session expired. Please start over.' });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Clean up pending token
|
|
585
|
+
setSetting(`totp_pending_login:${pending}`, '');
|
|
586
|
+
|
|
587
|
+
const secret = getSetting('totp_secret');
|
|
588
|
+
if (!secret) { res.status(400).json({ error: '2FA is not configured' }); return; }
|
|
589
|
+
|
|
590
|
+
// Try TOTP code first
|
|
591
|
+
let valid = verifyTOTPCode(code, secret);
|
|
592
|
+
|
|
593
|
+
// If not valid as TOTP, try as recovery code
|
|
594
|
+
if (!valid) {
|
|
595
|
+
const hashesJson = getSetting('totp_recovery_codes');
|
|
596
|
+
if (hashesJson) {
|
|
597
|
+
try {
|
|
598
|
+
const hashes = JSON.parse(hashesJson) as string[];
|
|
599
|
+
const result = verifyRecoveryCode(code, hashes);
|
|
600
|
+
if (result.valid) {
|
|
601
|
+
valid = true;
|
|
602
|
+
setSetting('totp_recovery_codes', JSON.stringify(result.remaining));
|
|
773
603
|
}
|
|
604
|
+
} catch {}
|
|
774
605
|
}
|
|
606
|
+
}
|
|
775
607
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
608
|
+
if (!valid) {
|
|
609
|
+
res.status(401).json({ error: 'Invalid code' });
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
780
612
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
).toISOString();
|
|
796
|
-
createTrustedDevice(deviceToken, 'Browser', deviceExpiry);
|
|
797
|
-
res.setHeader(
|
|
798
|
-
'Set-Cookie',
|
|
799
|
-
`fluxy_device=${deviceToken}; HttpOnly; Secure; SameSite=Strict; Max-Age=7776000; Path=/`
|
|
800
|
-
);
|
|
801
|
-
}
|
|
613
|
+
// Create session
|
|
614
|
+
deleteExpiredSessions();
|
|
615
|
+
const token = crypto.randomBytes(64).toString('hex');
|
|
616
|
+
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
617
|
+
createSession(token, expiresAt);
|
|
618
|
+
|
|
619
|
+
// Trust device if requested
|
|
620
|
+
if (trust === '1') {
|
|
621
|
+
deleteExpiredDevices();
|
|
622
|
+
const deviceToken = crypto.randomBytes(32).toString('hex');
|
|
623
|
+
const deviceExpiry = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
|
|
624
|
+
createTrustedDevice(deviceToken, 'Browser', deviceExpiry);
|
|
625
|
+
res.setHeader('Set-Cookie', `fluxy_device=${deviceToken}; HttpOnly; Secure; SameSite=Strict; Max-Age=7776000; Path=/`);
|
|
626
|
+
}
|
|
802
627
|
|
|
803
|
-
|
|
628
|
+
res.json({ token, expiresAt });
|
|
804
629
|
});
|
|
805
630
|
|
|
806
631
|
app.get('/api/portal/devices', (_req, res) => {
|
|
807
|
-
|
|
632
|
+
res.json(listTrustedDevices());
|
|
808
633
|
});
|
|
809
634
|
|
|
810
635
|
app.delete('/api/portal/devices/:id', (req, res) => {
|
|
811
|
-
|
|
812
|
-
|
|
636
|
+
deleteTrustedDevice(req.params.id);
|
|
637
|
+
res.json({ ok: true });
|
|
813
638
|
});
|
|
814
639
|
|
|
815
640
|
app.post('/api/portal/devices/revoke', (req, res) => {
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
}
|
|
821
|
-
deleteTrustedDevice(id);
|
|
822
|
-
res.json({ ok: true });
|
|
641
|
+
const { id } = req.body;
|
|
642
|
+
if (!id) { res.status(400).json({ error: 'Device ID required' }); return; }
|
|
643
|
+
deleteTrustedDevice(id);
|
|
644
|
+
res.json({ ok: true });
|
|
823
645
|
});
|
|
824
646
|
|
|
825
647
|
app.post('/api/onboard', (req, res) => {
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
log.ok(
|
|
889
|
-
`FLUXY.md: updated names — bot="${newBotName}", human="${newHumanName}"`
|
|
890
|
-
);
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
// Re-read config from disk to preserve relay/handle data written by registration
|
|
894
|
-
const currentCfg = loadConfig();
|
|
895
|
-
log.ok(
|
|
896
|
-
`Onboard: preserving relay data — token=${currentCfg.relay?.token ? 'yes' : 'no'}, username=${currentCfg.username || '(none)'}`
|
|
897
|
-
);
|
|
898
|
-
|
|
899
|
-
currentCfg.ai.provider = provider || '';
|
|
900
|
-
currentCfg.ai.model = model || '';
|
|
901
|
-
currentCfg.ai.baseUrl = baseUrl || undefined;
|
|
902
|
-
|
|
903
|
-
// Read OAuth token if no API key provided
|
|
904
|
-
if (!apiKey && provider === 'openai') {
|
|
905
|
-
currentCfg.ai.apiKey = readCodexAccessToken() || '';
|
|
906
|
-
} else if (!apiKey && provider === 'anthropic') {
|
|
907
|
-
currentCfg.ai.apiKey = readClaudeAccessToken() || '';
|
|
908
|
-
} else {
|
|
909
|
-
currentCfg.ai.apiKey = apiKey || '';
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
saveConfig(currentCfg);
|
|
913
|
-
|
|
914
|
-
res.json({ ok: true });
|
|
648
|
+
const { userName, agentName, provider, model, apiKey, baseUrl, portalUser, portalPass, whisperEnabled, whisperKey } = req.body;
|
|
649
|
+
|
|
650
|
+
// Read old names before overwriting (needed for FLUXY.md re-onboard)
|
|
651
|
+
const oldBotName = getSetting('agent_name') || '$BOT';
|
|
652
|
+
const oldHumanName = getSetting('user_name') || '$HUMAN';
|
|
653
|
+
|
|
654
|
+
setSetting('user_name', userName || '');
|
|
655
|
+
setSetting('agent_name', agentName || 'Fluxy');
|
|
656
|
+
setSetting('onboard_complete', 'true');
|
|
657
|
+
|
|
658
|
+
// Save portal credentials
|
|
659
|
+
if (portalUser) {
|
|
660
|
+
setSetting('portal_user', portalUser.trim().toLowerCase());
|
|
661
|
+
}
|
|
662
|
+
if (portalPass) {
|
|
663
|
+
setSetting('portal_pass', hashPassword(portalPass));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Save whisper config
|
|
667
|
+
setSetting('whisper_enabled', whisperEnabled ? 'true' : 'false');
|
|
668
|
+
if (whisperKey) {
|
|
669
|
+
setSetting('whisper_key', whisperKey);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Update bot and human names in FLUXY.md
|
|
673
|
+
// On first onboard: replaces $BOT / $HUMAN placeholders
|
|
674
|
+
// On re-onboard: replaces the previous names with the new ones
|
|
675
|
+
const newBotName = agentName || 'Fluxy';
|
|
676
|
+
const newHumanName = userName || 'Human';
|
|
677
|
+
const fluxyMdPath = path.join(WORKSPACE_DIR, 'FLUXY.md');
|
|
678
|
+
if (fs.existsSync(fluxyMdPath)) {
|
|
679
|
+
let fluxyContent = fs.readFileSync(fluxyMdPath, 'utf-8');
|
|
680
|
+
if (oldBotName !== newBotName) {
|
|
681
|
+
fluxyContent = fluxyContent.replace(new RegExp(oldBotName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newBotName);
|
|
682
|
+
}
|
|
683
|
+
if (oldHumanName !== newHumanName) {
|
|
684
|
+
fluxyContent = fluxyContent.replace(new RegExp(oldHumanName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newHumanName);
|
|
685
|
+
}
|
|
686
|
+
fs.writeFileSync(fluxyMdPath, fluxyContent, 'utf-8');
|
|
687
|
+
log.ok(`FLUXY.md: updated names — bot="${newBotName}", human="${newHumanName}"`);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Re-read config from disk to preserve relay/handle data written by registration
|
|
691
|
+
const currentCfg = loadConfig();
|
|
692
|
+
log.ok(`Onboard: preserving relay data — token=${currentCfg.relay?.token ? 'yes' : 'no'}, username=${currentCfg.username || '(none)'}`);
|
|
693
|
+
|
|
694
|
+
currentCfg.ai.provider = provider || '';
|
|
695
|
+
currentCfg.ai.model = model || '';
|
|
696
|
+
currentCfg.ai.baseUrl = baseUrl || undefined;
|
|
697
|
+
|
|
698
|
+
// Read OAuth token if no API key provided
|
|
699
|
+
if (!apiKey && provider === 'openai') {
|
|
700
|
+
currentCfg.ai.apiKey = readCodexAccessToken() || '';
|
|
701
|
+
} else if (!apiKey && provider === 'anthropic') {
|
|
702
|
+
currentCfg.ai.apiKey = readClaudeAccessToken() || '';
|
|
703
|
+
} else {
|
|
704
|
+
currentCfg.ai.apiKey = apiKey || '';
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
saveConfig(currentCfg);
|
|
708
|
+
|
|
709
|
+
res.json({ ok: true });
|
|
915
710
|
});
|
|
916
711
|
|
|
917
712
|
// ── Push notifications ──
|
|
918
713
|
|
|
919
714
|
app.get('/api/push/vapid-public-key', (_, res) => {
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
return;
|
|
924
|
-
}
|
|
925
|
-
res.json({ publicKey: key });
|
|
715
|
+
const key = getSetting('vapid_public_key');
|
|
716
|
+
if (!key) { res.status(500).json({ error: 'VAPID keys not initialized' }); return; }
|
|
717
|
+
res.json({ publicKey: key });
|
|
926
718
|
});
|
|
927
719
|
|
|
928
720
|
app.post('/api/push/subscribe', (req, res) => {
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
721
|
+
const { endpoint, keys } = req.body || {};
|
|
722
|
+
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
|
723
|
+
res.status(400).json({ error: 'Invalid subscription' });
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
addPushSubscription(endpoint, keys.p256dh, keys.auth);
|
|
727
|
+
res.json({ ok: true });
|
|
936
728
|
});
|
|
937
729
|
|
|
938
730
|
app.delete('/api/push/unsubscribe', (req, res) => {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
}
|
|
944
|
-
removePushSubscription(endpoint);
|
|
945
|
-
res.json({ ok: true });
|
|
731
|
+
const { endpoint } = req.body || {};
|
|
732
|
+
if (!endpoint) { res.status(400).json({ error: 'Missing endpoint' }); return; }
|
|
733
|
+
removePushSubscription(endpoint);
|
|
734
|
+
res.json({ ok: true });
|
|
946
735
|
});
|
|
947
736
|
|
|
948
737
|
app.post('/api/push/send', async (req, res) => {
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
738
|
+
const { title, body, tag, url } = req.body || {};
|
|
739
|
+
const subs = getAllPushSubscriptions();
|
|
740
|
+
log.info(`[push] Sending to ${subs.length} subscription(s): "${title}"`);
|
|
741
|
+
const payload = JSON.stringify({ title: title || 'Fluxy', body: body || '', tag: tag || 'fluxy', url: url || '/' });
|
|
742
|
+
|
|
743
|
+
const results = await Promise.allSettled(
|
|
744
|
+
subs.map(async (sub) => {
|
|
745
|
+
try {
|
|
746
|
+
await webpush.sendNotification(
|
|
747
|
+
{ endpoint: sub.endpoint, keys: { p256dh: sub.keys_p256dh, auth: sub.keys_auth } },
|
|
748
|
+
payload,
|
|
749
|
+
);
|
|
750
|
+
} catch (err: any) {
|
|
751
|
+
if (err.statusCode === 410 || err.statusCode === 404) {
|
|
752
|
+
removePushSubscription(sub.endpoint);
|
|
753
|
+
log.info(`[push] Removed expired subscription: ${sub.endpoint.slice(0, 60)}...`);
|
|
754
|
+
} else {
|
|
755
|
+
log.warn(`[push] Send failed: ${err.message}`);
|
|
756
|
+
}
|
|
757
|
+
throw err;
|
|
758
|
+
}
|
|
759
|
+
}),
|
|
760
|
+
);
|
|
958
761
|
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
try {
|
|
962
|
-
await webpush.sendNotification(
|
|
963
|
-
{
|
|
964
|
-
endpoint: sub.endpoint,
|
|
965
|
-
keys: { p256dh: sub.keys_p256dh, auth: sub.keys_auth }
|
|
966
|
-
},
|
|
967
|
-
payload
|
|
968
|
-
);
|
|
969
|
-
} catch (err: any) {
|
|
970
|
-
if (err.statusCode === 410 || err.statusCode === 404) {
|
|
971
|
-
removePushSubscription(sub.endpoint);
|
|
972
|
-
log.info(
|
|
973
|
-
`[push] Removed expired subscription: ${sub.endpoint.slice(0, 60)}...`
|
|
974
|
-
);
|
|
975
|
-
} else {
|
|
976
|
-
log.warn(`[push] Send failed: ${err.message}`);
|
|
977
|
-
}
|
|
978
|
-
throw err;
|
|
979
|
-
}
|
|
980
|
-
})
|
|
981
|
-
);
|
|
982
|
-
|
|
983
|
-
const sent = results.filter(r => r.status === 'fulfilled').length;
|
|
984
|
-
res.json({ sent, total: subs.length });
|
|
762
|
+
const sent = results.filter((r) => r.status === 'fulfilled').length;
|
|
763
|
+
res.json({ sent, total: subs.length });
|
|
985
764
|
});
|
|
986
765
|
|
|
987
766
|
app.get('/api/push/status', (req, res) => {
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
}
|
|
993
|
-
const sub = getPushSubscriptionByEndpoint(endpoint);
|
|
994
|
-
res.json({ subscribed: !!sub });
|
|
767
|
+
const endpoint = req.query.endpoint as string;
|
|
768
|
+
if (!endpoint) { res.json({ subscribed: false }); return; }
|
|
769
|
+
const sub = getPushSubscriptionByEndpoint(endpoint);
|
|
770
|
+
res.json({ subscribed: !!sub });
|
|
995
771
|
});
|
|
996
772
|
|
|
997
773
|
// ── Whisper transcription ──
|
|
998
774
|
|
|
999
|
-
app.post(
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
775
|
+
app.post('/api/whisper/transcribe', express.json({ limit: '10mb' }), async (req, res) => {
|
|
776
|
+
const whisperEnabled = getSetting('whisper_enabled');
|
|
777
|
+
const whisperKey = getSetting('whisper_key');
|
|
778
|
+
|
|
779
|
+
if (whisperEnabled !== 'true' || !whisperKey) {
|
|
780
|
+
res.status(400).json({ error: 'Whisper not enabled or API key missing' });
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const { audio } = req.body; // base64 string (no data URL prefix expected, but handle both)
|
|
785
|
+
if (!audio) {
|
|
786
|
+
res.status(400).json({ error: 'No audio data provided' });
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
try {
|
|
791
|
+
// Strip data URL prefix if present
|
|
792
|
+
const raw = audio.includes(',') ? audio.split(',')[1] : audio;
|
|
793
|
+
const audioBuffer = Buffer.from(raw, 'base64');
|
|
794
|
+
|
|
795
|
+
// Build multipart/form-data manually
|
|
796
|
+
const boundary = '----WhisperBoundary' + Date.now();
|
|
797
|
+
const CRLF = '\r\n';
|
|
798
|
+
|
|
799
|
+
const parts: Buffer[] = [];
|
|
800
|
+
|
|
801
|
+
// file part
|
|
802
|
+
parts.push(Buffer.from(
|
|
803
|
+
`--${boundary}${CRLF}` +
|
|
804
|
+
`Content-Disposition: form-data; name="file"; filename="audio.webm"${CRLF}` +
|
|
805
|
+
`Content-Type: audio/webm${CRLF}${CRLF}`
|
|
806
|
+
));
|
|
807
|
+
parts.push(audioBuffer);
|
|
808
|
+
parts.push(Buffer.from(CRLF));
|
|
809
|
+
|
|
810
|
+
// model part
|
|
811
|
+
parts.push(Buffer.from(
|
|
812
|
+
`--${boundary}${CRLF}` +
|
|
813
|
+
`Content-Disposition: form-data; name="model"${CRLF}${CRLF}` +
|
|
814
|
+
`whisper-1${CRLF}`
|
|
815
|
+
));
|
|
816
|
+
|
|
817
|
+
// closing boundary
|
|
818
|
+
parts.push(Buffer.from(`--${boundary}--${CRLF}`));
|
|
819
|
+
|
|
820
|
+
const body = Buffer.concat(parts);
|
|
821
|
+
|
|
822
|
+
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
823
|
+
method: 'POST',
|
|
824
|
+
headers: {
|
|
825
|
+
'Authorization': `Bearer ${whisperKey}`,
|
|
826
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
827
|
+
},
|
|
828
|
+
body,
|
|
829
|
+
});
|
|
1018
830
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
// Build multipart/form-data manually
|
|
1025
|
-
const boundary = '----WhisperBoundary' + Date.now();
|
|
1026
|
-
const CRLF = '\r\n';
|
|
1027
|
-
|
|
1028
|
-
const parts: Buffer[] = [];
|
|
1029
|
-
|
|
1030
|
-
// file part
|
|
1031
|
-
parts.push(
|
|
1032
|
-
Buffer.from(
|
|
1033
|
-
`--${boundary}${CRLF}` +
|
|
1034
|
-
`Content-Disposition: form-data; name="file"; filename="audio.webm"${CRLF}` +
|
|
1035
|
-
`Content-Type: audio/webm${CRLF}${CRLF}`
|
|
1036
|
-
)
|
|
1037
|
-
);
|
|
1038
|
-
parts.push(audioBuffer);
|
|
1039
|
-
parts.push(Buffer.from(CRLF));
|
|
1040
|
-
|
|
1041
|
-
// model part
|
|
1042
|
-
parts.push(
|
|
1043
|
-
Buffer.from(
|
|
1044
|
-
`--${boundary}${CRLF}` +
|
|
1045
|
-
`Content-Disposition: form-data; name="model"${CRLF}${CRLF}` +
|
|
1046
|
-
`whisper-1${CRLF}`
|
|
1047
|
-
)
|
|
1048
|
-
);
|
|
1049
|
-
|
|
1050
|
-
// closing boundary
|
|
1051
|
-
parts.push(Buffer.from(`--${boundary}--${CRLF}`));
|
|
1052
|
-
|
|
1053
|
-
const body = Buffer.concat(parts);
|
|
1054
|
-
|
|
1055
|
-
const response = await fetch(
|
|
1056
|
-
'https://api.openai.com/v1/audio/transcriptions',
|
|
1057
|
-
{
|
|
1058
|
-
method: 'POST',
|
|
1059
|
-
headers: {
|
|
1060
|
-
Authorization: `Bearer ${whisperKey}`,
|
|
1061
|
-
'Content-Type': `multipart/form-data; boundary=${boundary}`
|
|
1062
|
-
},
|
|
1063
|
-
body
|
|
1064
|
-
}
|
|
1065
|
-
);
|
|
1066
|
-
|
|
1067
|
-
if (!response.ok) {
|
|
1068
|
-
const errText = await response.text();
|
|
1069
|
-
log.warn(`Whisper API error: ${response.status} ${errText}`);
|
|
1070
|
-
res.status(502).json({ error: 'Whisper API error' });
|
|
1071
|
-
return;
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
const result = (await response.json()) as { text: string };
|
|
1075
|
-
res.json({ transcript: result.text });
|
|
1076
|
-
} catch (err: any) {
|
|
1077
|
-
log.warn(`Whisper transcription failed: ${err.message}`);
|
|
1078
|
-
res.status(500).json({ error: 'Transcription failed' });
|
|
1079
|
-
}
|
|
831
|
+
if (!response.ok) {
|
|
832
|
+
const errText = await response.text();
|
|
833
|
+
log.warn(`Whisper API error: ${response.status} ${errText}`);
|
|
834
|
+
res.status(502).json({ error: 'Whisper API error' });
|
|
835
|
+
return;
|
|
1080
836
|
}
|
|
1081
|
-
|
|
837
|
+
|
|
838
|
+
const result = await response.json() as { text: string };
|
|
839
|
+
res.json({ transcript: result.text });
|
|
840
|
+
} catch (err: any) {
|
|
841
|
+
log.warn(`Whisper transcription failed: ${err.message}`);
|
|
842
|
+
res.status(500).json({ error: 'Transcription failed' });
|
|
843
|
+
}
|
|
844
|
+
});
|
|
1082
845
|
|
|
1083
846
|
// Serve stored files (audio, images, documents)
|
|
1084
847
|
app.use('/api/files', express.static(paths.files));
|
|
@@ -1089,8 +852,4 @@ console.log('[worker] API-only mode — dashboard served by Vite dev server');
|
|
|
1089
852
|
// HTTP server (no WebSocket — chat lives in supervisor)
|
|
1090
853
|
const server = app.listen(port, () => log.ok(`Worker on port ${port}`));
|
|
1091
854
|
|
|
1092
|
-
process.on('SIGTERM', () => {
|
|
1093
|
-
closeDb();
|
|
1094
|
-
server.close();
|
|
1095
|
-
process.exit(0);
|
|
1096
|
-
});
|
|
855
|
+
process.on('SIGTERM', () => { closeDb(); server.close(); process.exit(0); });
|