@danieltmn/openbridge 0.6.3 → 0.7.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/CHANGELOG.md +222 -197
- package/README.md +41 -5
- package/docs/ARCHITECTURE.md +14 -6
- package/package.json +1 -1
- package/src/bridge/bridge.js +2587 -2495
- package/src/web/assets/app.js +4802 -4454
- package/src/web/routes.js +915 -914
- package/src/web/server.js +3 -1
- package/src/web/templates/chat.html +1468 -1374
package/src/web/routes.js
CHANGED
|
@@ -1,914 +1,915 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Router de la app (port de app/api.php). Mantiene el mismo contrato JSON y
|
|
5
|
-
* las mismas rutas (?action=...) que la version PHP, para que app.js y
|
|
6
|
-
* bridge.js funcionen sin cambios.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
const fsp = require('node:fs/promises');
|
|
10
|
-
const path = require('node:path');
|
|
11
|
-
const crypto = require('node:crypto');
|
|
12
|
-
const auth = require('../auth');
|
|
13
|
-
const config = require('../config');
|
|
14
|
-
const store = require('../store');
|
|
15
|
-
const paths = require('../paths');
|
|
16
|
-
const push = require('../push');
|
|
17
|
-
const jsonfile = require('../store/jsonfile');
|
|
18
|
-
|
|
19
|
-
const TEMPLATES = path.join(__dirname, 'templates');
|
|
20
|
-
|
|
21
|
-
// Hash dummy para igualar el costo de scrypt cuando el usuario no existe (evita
|
|
22
|
-
// filtrar si un nombre esta registrado por diferencias de tiempo).
|
|
23
|
-
const DUMMY_HASH = { algo: 'scrypt', salt: '00000000000000000000000000000000', hash: '00'.repeat(64), keylen: 64 };
|
|
24
|
-
|
|
25
|
-
// Acciones que solo puede hacer un admin.
|
|
26
|
-
const ADMIN_ONLY_ACTIONS = new Set(['session_delete']);
|
|
27
|
-
// Comandos del puente que mutan algo (procesos, tuneles, revertir).
|
|
28
|
-
const OC_MUTATING = new Set(['proc_start', 'proc_stop', 'tunnel_start', 'tunnel_stop', 'git_checkout']);
|
|
29
|
-
|
|
30
|
-
async function renderTpl(name, vars) {
|
|
31
|
-
const tpl = await fsp.readFile(path.join(TEMPLATES, name), 'utf8');
|
|
32
|
-
return tpl.replace(/\{\{([A-Z_]+)\}\}/g, (m, k) => (
|
|
33
|
-
Object.prototype.hasOwnProperty.call(vars, k) ? String(vars[k]) : m
|
|
34
|
-
));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function pickTheme(req, query, known) {
|
|
38
|
-
const cookies = auth.parseCookies(req.headers.cookie);
|
|
39
|
-
const fromUrl = String(query.get('theme') || '').replace(/[^a-z-]/g, '').toLowerCase();
|
|
40
|
-
const fromCookie = String(cookies['ob_theme'] || cookies['ocx_theme'] || '').replace(/[^a-z-]/g, '').toLowerCase();
|
|
41
|
-
if (fromUrl && known.includes(fromUrl)) return fromUrl;
|
|
42
|
-
if (fromCookie && known.includes(fromCookie)) return fromCookie;
|
|
43
|
-
return 'terminal';
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
// Paginas
|
|
48
|
-
// ---------------------------------------------------------------------------
|
|
49
|
-
async function handleIndex({ app, req, res }) {
|
|
50
|
-
const logged = auth.readSession(app, req);
|
|
51
|
-
res.writeHead(302, { Location: logged ? 'chat.php' : 'login.php' });
|
|
52
|
-
res.end();
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function handleLogout({ app, req, res }) {
|
|
56
|
-
auth.endSession(req, res);
|
|
57
|
-
res.writeHead(302, { Location: 'login.php' });
|
|
58
|
-
res.end();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function loginCsrf(req, res) {
|
|
62
|
-
const cookies = auth.parseCookies(req.headers.cookie);
|
|
63
|
-
let csrf = cookies['ob_csrf'];
|
|
64
|
-
if (!csrf) {
|
|
65
|
-
csrf = crypto.randomBytes(16).toString('hex');
|
|
66
|
-
res.setHeader('Set-Cookie', auth.serializeCookie('ob_csrf', csrf, req, 3600));
|
|
67
|
-
}
|
|
68
|
-
return csrf;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Ultimo usuario logueado (para prellenar el campo en el login).
|
|
72
|
-
function lastUser(req) {
|
|
73
|
-
const cookies = auth.parseCookies(req.headers.cookie);
|
|
74
|
-
return String(cookies['ob_lastuser'] || '').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 32);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async function handleLoginPage({ app, req, res, query }) {
|
|
78
|
-
if (auth.readSession(app, req)) {
|
|
79
|
-
res.writeHead(302, { Location: 'chat.php' });
|
|
80
|
-
return res.end();
|
|
81
|
-
}
|
|
82
|
-
const known = store.themesKnown();
|
|
83
|
-
const csrf = await loginCsrf(req, res);
|
|
84
|
-
const html = await renderTpl('login.html', {
|
|
85
|
-
THEME: pickTheme(req, query, known),
|
|
86
|
-
THEMES_JSON: JSON.stringify(store.themesIndex()),
|
|
87
|
-
ERROR_BLOCK: '',
|
|
88
|
-
CSRF: csrf,
|
|
89
|
-
USER_PREFILL: lastUser(req),
|
|
90
|
-
});
|
|
91
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
92
|
-
res.end(html);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function handleLogin({ app, req, res, query, form }) {
|
|
96
|
-
const cookies = auth.parseCookies(req.headers.cookie);
|
|
97
|
-
const csrfOk = form.csrf && cookies['ob_csrf'] && auth.safeEqual(form.csrf, cookies['ob_csrf']);
|
|
98
|
-
const known = store.themesKnown();
|
|
99
|
-
const renderError = async (msg) => {
|
|
100
|
-
const csrf = await loginCsrf(req, res);
|
|
101
|
-
const html = await renderTpl('login.html', {
|
|
102
|
-
THEME: pickTheme(req, query, known),
|
|
103
|
-
THEMES_JSON: JSON.stringify(store.themesIndex()),
|
|
104
|
-
ERROR_BLOCK: '<div class="error">\u26a0 ' + msg.replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c])) + '</div>',
|
|
105
|
-
CSRF: csrf,
|
|
106
|
-
USER_PREFILL: lastUser(req) || String(form.username || '').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 32),
|
|
107
|
-
});
|
|
108
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
109
|
-
res.end(html);
|
|
110
|
-
};
|
|
111
|
-
if (!csrfOk) return renderError('Sesion expirada, recarga la pagina.');
|
|
112
|
-
const username = String(form.username || '').trim();
|
|
113
|
-
const locked = auth.loginLockRemaining(req, username);
|
|
114
|
-
if (locked > 0) {
|
|
115
|
-
const mins = Math.max(1, Math.ceil(locked / 60));
|
|
116
|
-
return renderError('Demasiados intentos fallidos. Proba de nuevo en ' + mins + ' min.');
|
|
117
|
-
}
|
|
118
|
-
const password = String(form.password || '');
|
|
119
|
-
const user = config.findUser(app, username);
|
|
120
|
-
const passOk = config.verifyUserPassword(user, password);
|
|
121
|
-
if (!user) config.verifyPassword(password, DUMMY_HASH); // mismo costo si no existe
|
|
122
|
-
if (user && !user.disabled && passOk) {
|
|
123
|
-
auth.loginClear(req, username);
|
|
124
|
-
auth.startSession(app, req, res, user);
|
|
125
|
-
const prev = res.getHeader('Set-Cookie');
|
|
126
|
-
const jar = Array.isArray(prev) ? prev.slice() : (prev ? [prev] : []);
|
|
127
|
-
jar.push(auth.serializeCookie('ob_lastuser', user.name, req, 60 * 60 * 24 * 365));
|
|
128
|
-
res.setHeader('Set-Cookie', jar);
|
|
129
|
-
res.writeHead(302, { Location: 'chat.php' });
|
|
130
|
-
return res.end();
|
|
131
|
-
}
|
|
132
|
-
auth.loginRecordFailure(req, username);
|
|
133
|
-
return renderError('Usuario o contrasena incorrectos.');
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async function handleChat({ app, req, res, query }) {
|
|
137
|
-
if (!auth.readSession(app, req)) auth.rememberAutoLogin(app, req, res);
|
|
138
|
-
const session = auth.readSession(app, req);
|
|
139
|
-
if (!session) {
|
|
140
|
-
res.writeHead(302, { Location: 'login.php' });
|
|
141
|
-
return res.end();
|
|
142
|
-
}
|
|
143
|
-
const known = store.themesKnown();
|
|
144
|
-
const initial = parseInt(query.get('session') || '0', 10) || 0;
|
|
145
|
-
let ver = '0';
|
|
146
|
-
try { ver = String(Math.floor((await fsp.stat(path.join(__dirname, 'assets', 'app.js'))).mtimeMs)); } catch (e) { /* nada */ }
|
|
147
|
-
const html = await renderTpl('chat.html', {
|
|
148
|
-
THEME: pickTheme(req, query, known),
|
|
149
|
-
CSRF: session.c,
|
|
150
|
-
USER_NAME: session.name,
|
|
151
|
-
USER_ROLE: session.role,
|
|
152
|
-
INITIAL_SESSION: initial > 0 ? String(initial) : 'null',
|
|
153
|
-
THEMES_JSON: JSON.stringify(store.themesIndex()),
|
|
154
|
-
SSE_DISABLED: 'false',
|
|
155
|
-
PUSH_ENABLED: push.pushEnabled(app) ? 'true' : 'false',
|
|
156
|
-
PUSH_KEY: push.publicKeyBase64url(app),
|
|
157
|
-
APPJS_VER: encodeURIComponent(ver),
|
|
158
|
-
});
|
|
159
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
160
|
-
res.end(html);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// ---------------------------------------------------------------------------
|
|
164
|
-
// Helpers de puente
|
|
165
|
-
// ---------------------------------------------------------------------------
|
|
166
|
-
function headerBridge(req) {
|
|
167
|
-
const h = String(req.headers['x-bridge-id'] || '').trim();
|
|
168
|
-
return store.bridgeValidId(h) ? h : '';
|
|
169
|
-
}
|
|
170
|
-
function reqBridge(req, query) {
|
|
171
|
-
const h = headerBridge(req);
|
|
172
|
-
if (h) return h;
|
|
173
|
-
const q = String(query.get('bridge') || '').trim();
|
|
174
|
-
return store.bridgeValidId(q) ? q : '';
|
|
175
|
-
}
|
|
176
|
-
function headerBridgeName(req) {
|
|
177
|
-
const h = String(req.headers['x-bridge-name'] || '').trim();
|
|
178
|
-
return h.replace(/[^\p{L}\p{N} ._-]/gu, '').slice(0, 40);
|
|
179
|
-
}
|
|
180
|
-
async function webBridge(req, query) {
|
|
181
|
-
const b = reqBridge(req, query);
|
|
182
|
-
if (b) return b;
|
|
183
|
-
return await store.soleBridgeId();
|
|
184
|
-
}
|
|
185
|
-
function bodyBridge(req, query, body) {
|
|
186
|
-
const b = body && typeof body.bridge === 'string' ? body.bridge : '';
|
|
187
|
-
if (store.bridgeValidId(b)) return b;
|
|
188
|
-
return null; // lo resuelve webBridge
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// ---------------------------------------------------------------------------
|
|
192
|
-
// API
|
|
193
|
-
// ---------------------------------------------------------------------------
|
|
194
|
-
const OC_ALLOWED = {
|
|
195
|
-
models: [],
|
|
196
|
-
session_list: [],
|
|
197
|
-
session_info: ['arg'],
|
|
198
|
-
opencode_version: [],
|
|
199
|
-
mcp_list: [],
|
|
200
|
-
fs_list: ['path'],
|
|
201
|
-
fs_read: ['path'],
|
|
202
|
-
git_status: ['path'],
|
|
203
|
-
git_diff: ['path'],
|
|
204
|
-
git_checkout: ['path', 'path'],
|
|
205
|
-
tunnel_start: ['arg'],
|
|
206
|
-
tunnel_stop: ['arg'],
|
|
207
|
-
tunnel_list: [],
|
|
208
|
-
proc_start: ['path', 'cmd'],
|
|
209
|
-
proc_stop: ['arg'],
|
|
210
|
-
proc_list: [],
|
|
211
|
-
proc_log: ['arg', 'arg'],
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if (tipo === '
|
|
219
|
-
return /^[
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
const
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (!auth.
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
const
|
|
279
|
-
const
|
|
280
|
-
const
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
if (
|
|
284
|
-
if (!(await store.
|
|
285
|
-
if (
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (s
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (!auth.
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
if (model
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if (
|
|
315
|
-
if (
|
|
316
|
-
sdata.sessions[i].
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
||
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
if (!auth.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if (msg.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
return ok({ ok:
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if (img
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
if (
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
await store.
|
|
399
|
-
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
if (
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const
|
|
450
|
-
const
|
|
451
|
-
const
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
return ok(result);
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
if (!auth.
|
|
474
|
-
|
|
475
|
-
const
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
if (cat.
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
if (!auth.
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
const
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
if (
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
if (!auth.
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
await store.
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
if (
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
if (
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
if (
|
|
552
|
-
|
|
553
|
-
msg.
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
msg.
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
:
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
case '
|
|
613
|
-
case '
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
const
|
|
628
|
-
const
|
|
629
|
-
const
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
if (
|
|
633
|
-
if (text
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
if (
|
|
644
|
-
data.messages[draftIdx].
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
if (
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
const
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
if (
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
const
|
|
670
|
-
const
|
|
671
|
-
const
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
if (
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
let
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
msg.
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
m.
|
|
709
|
-
m.
|
|
710
|
-
m.
|
|
711
|
-
m.
|
|
712
|
-
|
|
713
|
-
if (
|
|
714
|
-
if (
|
|
715
|
-
if (
|
|
716
|
-
if (
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
data.nextId
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
if (
|
|
724
|
-
if (
|
|
725
|
-
if (
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
const
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
const
|
|
740
|
-
|
|
741
|
-
return ok({ ok:
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
const
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
const
|
|
754
|
-
const
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
return ok({ ok:
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
const
|
|
769
|
-
const
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
if (
|
|
773
|
-
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
const
|
|
787
|
-
const
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
if (!auth.
|
|
802
|
-
|
|
803
|
-
const
|
|
804
|
-
const
|
|
805
|
-
const
|
|
806
|
-
const
|
|
807
|
-
const
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
if (
|
|
811
|
-
if (
|
|
812
|
-
if (
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
'
|
|
843
|
-
'
|
|
844
|
-
'
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
res.
|
|
849
|
-
res.write('
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
if (
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
const
|
|
884
|
-
|
|
885
|
-
if (
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Router de la app (port de app/api.php). Mantiene el mismo contrato JSON y
|
|
5
|
+
* las mismas rutas (?action=...) que la version PHP, para que app.js y
|
|
6
|
+
* bridge.js funcionen sin cambios.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fsp = require('node:fs/promises');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const crypto = require('node:crypto');
|
|
12
|
+
const auth = require('../auth');
|
|
13
|
+
const config = require('../config');
|
|
14
|
+
const store = require('../store');
|
|
15
|
+
const paths = require('../paths');
|
|
16
|
+
const push = require('../push');
|
|
17
|
+
const jsonfile = require('../store/jsonfile');
|
|
18
|
+
|
|
19
|
+
const TEMPLATES = path.join(__dirname, 'templates');
|
|
20
|
+
|
|
21
|
+
// Hash dummy para igualar el costo de scrypt cuando el usuario no existe (evita
|
|
22
|
+
// filtrar si un nombre esta registrado por diferencias de tiempo).
|
|
23
|
+
const DUMMY_HASH = { algo: 'scrypt', salt: '00000000000000000000000000000000', hash: '00'.repeat(64), keylen: 64 };
|
|
24
|
+
|
|
25
|
+
// Acciones que solo puede hacer un admin.
|
|
26
|
+
const ADMIN_ONLY_ACTIONS = new Set(['session_delete']);
|
|
27
|
+
// Comandos del puente que mutan algo (procesos, tuneles, revertir).
|
|
28
|
+
const OC_MUTATING = new Set(['proc_start', 'proc_stop', 'tunnel_start', 'tunnel_stop', 'git_checkout']);
|
|
29
|
+
|
|
30
|
+
async function renderTpl(name, vars) {
|
|
31
|
+
const tpl = await fsp.readFile(path.join(TEMPLATES, name), 'utf8');
|
|
32
|
+
return tpl.replace(/\{\{([A-Z_]+)\}\}/g, (m, k) => (
|
|
33
|
+
Object.prototype.hasOwnProperty.call(vars, k) ? String(vars[k]) : m
|
|
34
|
+
));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pickTheme(req, query, known) {
|
|
38
|
+
const cookies = auth.parseCookies(req.headers.cookie);
|
|
39
|
+
const fromUrl = String(query.get('theme') || '').replace(/[^a-z-]/g, '').toLowerCase();
|
|
40
|
+
const fromCookie = String(cookies['ob_theme'] || cookies['ocx_theme'] || '').replace(/[^a-z-]/g, '').toLowerCase();
|
|
41
|
+
if (fromUrl && known.includes(fromUrl)) return fromUrl;
|
|
42
|
+
if (fromCookie && known.includes(fromCookie)) return fromCookie;
|
|
43
|
+
return 'terminal';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Paginas
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
async function handleIndex({ app, req, res }) {
|
|
50
|
+
const logged = auth.readSession(app, req);
|
|
51
|
+
res.writeHead(302, { Location: logged ? 'chat.php' : 'login.php' });
|
|
52
|
+
res.end();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function handleLogout({ app, req, res }) {
|
|
56
|
+
auth.endSession(req, res);
|
|
57
|
+
res.writeHead(302, { Location: 'login.php' });
|
|
58
|
+
res.end();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function loginCsrf(req, res) {
|
|
62
|
+
const cookies = auth.parseCookies(req.headers.cookie);
|
|
63
|
+
let csrf = cookies['ob_csrf'];
|
|
64
|
+
if (!csrf) {
|
|
65
|
+
csrf = crypto.randomBytes(16).toString('hex');
|
|
66
|
+
res.setHeader('Set-Cookie', auth.serializeCookie('ob_csrf', csrf, req, 3600));
|
|
67
|
+
}
|
|
68
|
+
return csrf;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Ultimo usuario logueado (para prellenar el campo en el login).
|
|
72
|
+
function lastUser(req) {
|
|
73
|
+
const cookies = auth.parseCookies(req.headers.cookie);
|
|
74
|
+
return String(cookies['ob_lastuser'] || '').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 32);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function handleLoginPage({ app, req, res, query }) {
|
|
78
|
+
if (auth.readSession(app, req)) {
|
|
79
|
+
res.writeHead(302, { Location: 'chat.php' });
|
|
80
|
+
return res.end();
|
|
81
|
+
}
|
|
82
|
+
const known = store.themesKnown();
|
|
83
|
+
const csrf = await loginCsrf(req, res);
|
|
84
|
+
const html = await renderTpl('login.html', {
|
|
85
|
+
THEME: pickTheme(req, query, known),
|
|
86
|
+
THEMES_JSON: JSON.stringify(store.themesIndex()),
|
|
87
|
+
ERROR_BLOCK: '',
|
|
88
|
+
CSRF: csrf,
|
|
89
|
+
USER_PREFILL: lastUser(req),
|
|
90
|
+
});
|
|
91
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
92
|
+
res.end(html);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function handleLogin({ app, req, res, query, form }) {
|
|
96
|
+
const cookies = auth.parseCookies(req.headers.cookie);
|
|
97
|
+
const csrfOk = form.csrf && cookies['ob_csrf'] && auth.safeEqual(form.csrf, cookies['ob_csrf']);
|
|
98
|
+
const known = store.themesKnown();
|
|
99
|
+
const renderError = async (msg) => {
|
|
100
|
+
const csrf = await loginCsrf(req, res);
|
|
101
|
+
const html = await renderTpl('login.html', {
|
|
102
|
+
THEME: pickTheme(req, query, known),
|
|
103
|
+
THEMES_JSON: JSON.stringify(store.themesIndex()),
|
|
104
|
+
ERROR_BLOCK: '<div class="error">\u26a0 ' + msg.replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c])) + '</div>',
|
|
105
|
+
CSRF: csrf,
|
|
106
|
+
USER_PREFILL: lastUser(req) || String(form.username || '').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 32),
|
|
107
|
+
});
|
|
108
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
109
|
+
res.end(html);
|
|
110
|
+
};
|
|
111
|
+
if (!csrfOk) return renderError('Sesion expirada, recarga la pagina.');
|
|
112
|
+
const username = String(form.username || '').trim();
|
|
113
|
+
const locked = auth.loginLockRemaining(req, username);
|
|
114
|
+
if (locked > 0) {
|
|
115
|
+
const mins = Math.max(1, Math.ceil(locked / 60));
|
|
116
|
+
return renderError('Demasiados intentos fallidos. Proba de nuevo en ' + mins + ' min.');
|
|
117
|
+
}
|
|
118
|
+
const password = String(form.password || '');
|
|
119
|
+
const user = config.findUser(app, username);
|
|
120
|
+
const passOk = config.verifyUserPassword(user, password);
|
|
121
|
+
if (!user) config.verifyPassword(password, DUMMY_HASH); // mismo costo si no existe
|
|
122
|
+
if (user && !user.disabled && passOk) {
|
|
123
|
+
auth.loginClear(req, username);
|
|
124
|
+
auth.startSession(app, req, res, user);
|
|
125
|
+
const prev = res.getHeader('Set-Cookie');
|
|
126
|
+
const jar = Array.isArray(prev) ? prev.slice() : (prev ? [prev] : []);
|
|
127
|
+
jar.push(auth.serializeCookie('ob_lastuser', user.name, req, 60 * 60 * 24 * 365));
|
|
128
|
+
res.setHeader('Set-Cookie', jar);
|
|
129
|
+
res.writeHead(302, { Location: 'chat.php' });
|
|
130
|
+
return res.end();
|
|
131
|
+
}
|
|
132
|
+
auth.loginRecordFailure(req, username);
|
|
133
|
+
return renderError('Usuario o contrasena incorrectos.');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function handleChat({ app, req, res, query }) {
|
|
137
|
+
if (!auth.readSession(app, req)) auth.rememberAutoLogin(app, req, res);
|
|
138
|
+
const session = auth.readSession(app, req);
|
|
139
|
+
if (!session) {
|
|
140
|
+
res.writeHead(302, { Location: 'login.php' });
|
|
141
|
+
return res.end();
|
|
142
|
+
}
|
|
143
|
+
const known = store.themesKnown();
|
|
144
|
+
const initial = parseInt(query.get('session') || '0', 10) || 0;
|
|
145
|
+
let ver = '0';
|
|
146
|
+
try { ver = String(Math.floor((await fsp.stat(path.join(__dirname, 'assets', 'app.js'))).mtimeMs)); } catch (e) { /* nada */ }
|
|
147
|
+
const html = await renderTpl('chat.html', {
|
|
148
|
+
THEME: pickTheme(req, query, known),
|
|
149
|
+
CSRF: session.c,
|
|
150
|
+
USER_NAME: session.name,
|
|
151
|
+
USER_ROLE: session.role,
|
|
152
|
+
INITIAL_SESSION: initial > 0 ? String(initial) : 'null',
|
|
153
|
+
THEMES_JSON: JSON.stringify(store.themesIndex()),
|
|
154
|
+
SSE_DISABLED: 'false',
|
|
155
|
+
PUSH_ENABLED: push.pushEnabled(app) ? 'true' : 'false',
|
|
156
|
+
PUSH_KEY: push.publicKeyBase64url(app),
|
|
157
|
+
APPJS_VER: encodeURIComponent(ver),
|
|
158
|
+
});
|
|
159
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
160
|
+
res.end(html);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Helpers de puente
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
function headerBridge(req) {
|
|
167
|
+
const h = String(req.headers['x-bridge-id'] || '').trim();
|
|
168
|
+
return store.bridgeValidId(h) ? h : '';
|
|
169
|
+
}
|
|
170
|
+
function reqBridge(req, query) {
|
|
171
|
+
const h = headerBridge(req);
|
|
172
|
+
if (h) return h;
|
|
173
|
+
const q = String(query.get('bridge') || '').trim();
|
|
174
|
+
return store.bridgeValidId(q) ? q : '';
|
|
175
|
+
}
|
|
176
|
+
function headerBridgeName(req) {
|
|
177
|
+
const h = String(req.headers['x-bridge-name'] || '').trim();
|
|
178
|
+
return h.replace(/[^\p{L}\p{N} ._-]/gu, '').slice(0, 40);
|
|
179
|
+
}
|
|
180
|
+
async function webBridge(req, query) {
|
|
181
|
+
const b = reqBridge(req, query);
|
|
182
|
+
if (b) return b;
|
|
183
|
+
return await store.soleBridgeId();
|
|
184
|
+
}
|
|
185
|
+
function bodyBridge(req, query, body) {
|
|
186
|
+
const b = body && typeof body.bridge === 'string' ? body.bridge : '';
|
|
187
|
+
if (store.bridgeValidId(b)) return b;
|
|
188
|
+
return null; // lo resuelve webBridge
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// API
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
const OC_ALLOWED = {
|
|
195
|
+
models: [],
|
|
196
|
+
session_list: [],
|
|
197
|
+
session_info: ['arg'],
|
|
198
|
+
opencode_version: [],
|
|
199
|
+
mcp_list: [],
|
|
200
|
+
fs_list: ['path'],
|
|
201
|
+
fs_read: ['path'],
|
|
202
|
+
git_status: ['path'],
|
|
203
|
+
git_diff: ['path'],
|
|
204
|
+
git_checkout: ['path', 'path'],
|
|
205
|
+
tunnel_start: ['arg'],
|
|
206
|
+
tunnel_stop: ['arg'],
|
|
207
|
+
tunnel_list: [],
|
|
208
|
+
proc_start: ['path', 'cmd'],
|
|
209
|
+
proc_stop: ['arg'],
|
|
210
|
+
proc_list: [],
|
|
211
|
+
proc_log: ['arg', 'arg'],
|
|
212
|
+
proc_detect: ['path'],
|
|
213
|
+
port_free: ['arg'],
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
function ocArgValido(tipo, valor) {
|
|
217
|
+
const a = String(valor);
|
|
218
|
+
if (tipo === 'path') return Array.from(a).length <= 300 && !a.includes('..') && !/[\x00-\x1f\x7f]/.test(a);
|
|
219
|
+
if (tipo === 'cmd') return Array.from(a).length <= 200 && /^[\p{L}\p{N} _\-.:@/+=]{1,200}$/u.test(a);
|
|
220
|
+
return /^[A-Za-z0-9_\-./]{1,80}$/.test(a);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function imageOk(img) {
|
|
224
|
+
return /^data:image\/(png|jpe?g|webp|gif);base64,[A-Za-z0-9+/=]+$/.test(img);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function handleApi(ctx) {
|
|
228
|
+
const { app, req, res, method, query, body } = ctx;
|
|
229
|
+
const action = String(query.get('action') || '');
|
|
230
|
+
const ok = (data, code = 200) => auth.json(res, code, data);
|
|
231
|
+
|
|
232
|
+
if (action === 'ping') return ok({ ok: true, now: store.nowIso() });
|
|
233
|
+
|
|
234
|
+
switch (action) {
|
|
235
|
+
case 'catalog': {
|
|
236
|
+
if (!auth.requireLogin(app, req, res)) return;
|
|
237
|
+
const bridge = await webBridge(req, query);
|
|
238
|
+
const file = paths.bridgeCatalogFile(bridge);
|
|
239
|
+
const cat = await store.catalogRead(file);
|
|
240
|
+
const ver = store.catalogVersion(cat);
|
|
241
|
+
const v = String(query.get('v') || '');
|
|
242
|
+
const live = await store.bridgesSummary();
|
|
243
|
+
const overlay = await store.bridgeLiveOverlay(cat, bridge);
|
|
244
|
+
if (v !== '' && v === ver) {
|
|
245
|
+
return ok({ ok: true, changed: false, cat_ver: ver, bridge, bridges: live, online_ts: overlay.last_online_ts || '' });
|
|
246
|
+
}
|
|
247
|
+
return ok({ ok: true, changed: true, catalog: overlay, cat_ver: ver, bridge, bridges: live, online_ts: overlay.last_online_ts || '' });
|
|
248
|
+
}
|
|
249
|
+
case 'bootstrap': {
|
|
250
|
+
if (!auth.requireLogin(app, req, res)) return;
|
|
251
|
+
const bridge = await webBridge(req, query);
|
|
252
|
+
const file = paths.bridgeCatalogFile(bridge);
|
|
253
|
+
const cat = await store.catalogRead(file);
|
|
254
|
+
const ver = store.catalogVersion(cat);
|
|
255
|
+
const v = String(query.get('v') || '');
|
|
256
|
+
const changed = !(v !== '' && v === ver);
|
|
257
|
+
const overlay = await store.bridgeLiveOverlay(cat, bridge);
|
|
258
|
+
const out = {
|
|
259
|
+
ok: true,
|
|
260
|
+
changed,
|
|
261
|
+
cat_ver: ver,
|
|
262
|
+
bridge,
|
|
263
|
+
bridges: await store.bridgesSummary(),
|
|
264
|
+
sessions: await store.sessionsListFull(),
|
|
265
|
+
online_ts: overlay.last_online_ts || '',
|
|
266
|
+
};
|
|
267
|
+
if (changed) out.catalog = overlay;
|
|
268
|
+
return ok(out);
|
|
269
|
+
}
|
|
270
|
+
case 'sessions': {
|
|
271
|
+
if (!auth.requireLogin(app, req, res)) return;
|
|
272
|
+
return ok({ ok: true, sessions: await store.sessionsListFull() });
|
|
273
|
+
}
|
|
274
|
+
case 'session_create': {
|
|
275
|
+
if (!auth.requireLogin(app, req, res)) return;
|
|
276
|
+
if (!auth.requireCsrf(app, req, res)) return;
|
|
277
|
+
const name = String(body.name || '').trim();
|
|
278
|
+
const folder = String(body.folder || '');
|
|
279
|
+
const model = String(body.model || '');
|
|
280
|
+
const agent = String(body.agent || 'build');
|
|
281
|
+
const bridge = bodyBridge(req, query, body) || await webBridge(req, query);
|
|
282
|
+
const file = paths.bridgeCatalogFile(bridge);
|
|
283
|
+
if (folder === '' || (await store.folderPathInCatalog(folder, file)) === null) return ok({ ok: false, error: 'Carpeta no disponible' }, 400);
|
|
284
|
+
if (model === '' || !(await store.modelInCatalog(model, file))) return ok({ ok: false, error: 'Modelo no disponible' }, 400);
|
|
285
|
+
if (!(await store.agentInCatalog(agent, file))) return ok({ ok: false, error: 'Agente no disponible' }, 400);
|
|
286
|
+
if (Array.from(name).length > 60) return ok({ ok: false, error: 'Nombre demasiado largo' }, 400);
|
|
287
|
+
const id = await store.addSession(name, folder, model, agent, null, bridge);
|
|
288
|
+
return ok({ ok: true, session: await store.getSession(id) });
|
|
289
|
+
}
|
|
290
|
+
case 'session_delete': {
|
|
291
|
+
const s = auth.requireCsrf(app, req, res);
|
|
292
|
+
if (!s) return;
|
|
293
|
+
if (s.role !== 'admin') return ok({ ok: false, error: 'Permiso insuficiente' }, 403);
|
|
294
|
+
const id = parseInt(body.id, 10) || 0;
|
|
295
|
+
if (id <= 0) return ok({ ok: false, error: 'id invalido' }, 400);
|
|
296
|
+
await store.deleteSession(id);
|
|
297
|
+
return ok({ ok: true });
|
|
298
|
+
}
|
|
299
|
+
case 'session_update': {
|
|
300
|
+
if (!auth.requireLogin(app, req, res)) return;
|
|
301
|
+
if (!auth.requireCsrf(app, req, res)) return;
|
|
302
|
+
const id = parseInt(body.id, 10) || 0;
|
|
303
|
+
const sess = id > 0 ? await store.getSession(id) : null;
|
|
304
|
+
if (!sess) return ok({ ok: false, error: 'Sesion no encontrada' }, 404);
|
|
305
|
+
const file = paths.bridgeCatalogFile(store.sessionBridge(sess));
|
|
306
|
+
const model = String(body.model || '').trim();
|
|
307
|
+
const agent = String(body.agent || '').trim();
|
|
308
|
+
if (model === '' && agent === '') return ok({ ok: false, error: 'Nada que cambiar' }, 400);
|
|
309
|
+
if (model !== '' && !(await store.modelInCatalog(model, file))) return ok({ ok: false, error: 'Modelo no disponible' }, 400);
|
|
310
|
+
if (agent !== '' && !(await store.agentInCatalog(agent, file))) return ok({ ok: false, error: 'Agente no disponible' }, 400);
|
|
311
|
+
let updated = null;
|
|
312
|
+
await store.sessionsUpdate((sdata) => {
|
|
313
|
+
const i = sdata.sessions.findIndex((s) => parseInt(s.id, 10) === id);
|
|
314
|
+
if (i < 0) return false;
|
|
315
|
+
if (model !== '') sdata.sessions[i].model = model;
|
|
316
|
+
if (agent !== '') sdata.sessions[i].agent = agent;
|
|
317
|
+
sdata.sessions[i].last_ts = store.nowIso();
|
|
318
|
+
updated = sdata.sessions[i];
|
|
319
|
+
});
|
|
320
|
+
return ok({ ok: true, session: updated });
|
|
321
|
+
}
|
|
322
|
+
case 'history': {
|
|
323
|
+
if (!auth.requireLogin(app, req, res)) return;
|
|
324
|
+
const sid = parseInt(query.get('session') || '0', 10) || 0;
|
|
325
|
+
const sess = sid > 0 ? await store.getSession(sid) : null;
|
|
326
|
+
if (!sess) return ok({ ok: false, error: 'Sesion no encontrada' }, 404);
|
|
327
|
+
const data = await store.messagesRead(sid);
|
|
328
|
+
if (store.messagesHealStaleStreaming(data, Date.now() - store.STALE_PROCESSING_SECONDS * 1000)) {
|
|
329
|
+
await jsonfile.writeAtomic(paths.messagesFile(sid), data);
|
|
330
|
+
}
|
|
331
|
+
const since = parseInt(query.get('since') || '0', 10) || 0;
|
|
332
|
+
const sinceTs = String(query.get('ts') || '');
|
|
333
|
+
let msgs = data.messages;
|
|
334
|
+
if (since > 0) {
|
|
335
|
+
const tsCut = sinceTs ? Date.parse(sinceTs) : 0;
|
|
336
|
+
msgs = msgs.filter((m) => parseInt(m.id, 10) > since
|
|
337
|
+
|| m.status === 'streaming'
|
|
338
|
+
|| (m.answered_ts && tsCut > 0 && Date.parse(m.answered_ts) > tsCut));
|
|
339
|
+
}
|
|
340
|
+
return ok({ ok: true, session: sess, messages: msgs });
|
|
341
|
+
}
|
|
342
|
+
case 'cancel': {
|
|
343
|
+
if (!auth.requireLogin(app, req, res)) return;
|
|
344
|
+
if (!auth.requireCsrf(app, req, res)) return;
|
|
345
|
+
const sid = parseInt(body.session, 10) || 0;
|
|
346
|
+
if (sid <= 0 || !(await store.getSession(sid))) return ok({ ok: false, error: 'Sesion no encontrada' }, 404);
|
|
347
|
+
let marked = 0;
|
|
348
|
+
await store.messagesUpdate(sid, (data) => {
|
|
349
|
+
for (const msg of data.messages) {
|
|
350
|
+
if (msg.role !== 'user') continue;
|
|
351
|
+
if (msg.status === 'pending') { msg.status = 'canceled'; msg.canceled_ts = store.nowIso(); marked++; }
|
|
352
|
+
else if (msg.status === 'processing' && !msg.cancel_requested) { msg.cancel_requested = true; marked++; }
|
|
353
|
+
}
|
|
354
|
+
if (!marked) return false;
|
|
355
|
+
});
|
|
356
|
+
if (!marked) return ok({ ok: false, error: 'No hay nada en curso para cancelar' }, 400);
|
|
357
|
+
return ok({ ok: true, marked });
|
|
358
|
+
}
|
|
359
|
+
case 'cancel_status': {
|
|
360
|
+
if (!auth.checkBridgeToken(app, req)) return ok({ ok: false, error: 'Token invalido' }, 401);
|
|
361
|
+
const sid = parseInt(body.session_id, 10) || 0;
|
|
362
|
+
if (sid <= 0) return ok({ ok: false, error: 'session_id requerido' }, 400);
|
|
363
|
+
const data = await store.messagesRead(sid);
|
|
364
|
+
for (const msg of data.messages) {
|
|
365
|
+
if (msg.role === 'user' && msg.cancel_requested) {
|
|
366
|
+
return ok({ ok: true, cancel: true, user_id: parseInt(msg.id, 10) });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return ok({ ok: true, cancel: false });
|
|
370
|
+
}
|
|
371
|
+
case 'send': {
|
|
372
|
+
const s = auth.requireCsrf(app, req, res);
|
|
373
|
+
if (!s) return;
|
|
374
|
+
const sid = parseInt(body.session, 10) || 0;
|
|
375
|
+
const sess = sid > 0 ? await store.getSession(sid) : null;
|
|
376
|
+
if (!sess) return ok({ ok: false, error: 'Sesion no encontrada' }, 404);
|
|
377
|
+
const text = String(body.text || '').trim();
|
|
378
|
+
const img = String(body.image || '').trim();
|
|
379
|
+
if (img !== '') {
|
|
380
|
+
if (!imageOk(img)) return ok({ ok: false, error: 'Imagen invalida' }, 400);
|
|
381
|
+
if (img.length * 3 / 4 > 4 * 1024 * 1024) return ok({ ok: false, error: 'Imagen demasiado grande (max 4 MB)' }, 400);
|
|
382
|
+
}
|
|
383
|
+
if (text === '' && img === '') return ok({ ok: false, error: 'Mensaje vacio' }, 400);
|
|
384
|
+
if (Array.from(text).length > 10000) return ok({ ok: false, error: 'Mensaje demasiado largo' }, 400);
|
|
385
|
+
const extra = { agent: String(sess.agent || 'build'), author: s.name };
|
|
386
|
+
if (img !== '') extra.img = img;
|
|
387
|
+
const id = await store.addMessage(sid, 'user', text, 'pending', extra);
|
|
388
|
+
if (store.sessionHasDefaultName(sess)) {
|
|
389
|
+
const t = store.sessionTitleFromPrompt(text);
|
|
390
|
+
if (t !== '') await store.sessionRename(sid, t);
|
|
391
|
+
}
|
|
392
|
+
return ok({ ok: true, id });
|
|
393
|
+
}
|
|
394
|
+
case 'sync_catalog': {
|
|
395
|
+
if (!auth.checkBridgeToken(app, req)) return ok({ ok: false, error: 'Token invalido' }, 401);
|
|
396
|
+
const bridge = reqBridge(req, query);
|
|
397
|
+
const name = headerBridgeName(req);
|
|
398
|
+
await store.bridgeRegisterFirst(bridge, name);
|
|
399
|
+
await store.bridgeRegistryUpsert(bridge, name);
|
|
400
|
+
const file = paths.bridgeCatalogFile(bridge);
|
|
401
|
+
const folders = [];
|
|
402
|
+
for (const f of (Array.isArray(body.folders) ? body.folders : [])) {
|
|
403
|
+
if (f && typeof f === 'object' && f.name !== undefined && f.path !== undefined) {
|
|
404
|
+
folders.push({ name: store.mbSubstr(String(f.name), 0, 60), path: store.mbSubstr(String(f.path), 0, 500) });
|
|
405
|
+
} else if (typeof f === 'string') {
|
|
406
|
+
folders.push({ name: store.mbSubstr(f, 0, 60), path: store.mbSubstr(f, 0, 500) });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const models = (Array.isArray(body.models) ? body.models : [])
|
|
410
|
+
.filter((m) => typeof m === 'string' && m.trim() !== '').map((m) => store.mbSubstr(m.trim(), 0, 120));
|
|
411
|
+
const modelsFull = {};
|
|
412
|
+
if (body.models_full && typeof body.models_full === 'object') {
|
|
413
|
+
let count = 0;
|
|
414
|
+
for (const prov of Object.keys(body.models_full)) {
|
|
415
|
+
if (count >= 80) break;
|
|
416
|
+
const list = body.models_full[prov];
|
|
417
|
+
if (typeof prov !== 'string' || prov === '' || !Array.isArray(list)) continue;
|
|
418
|
+
const clean = list.filter((m) => typeof m === 'string' && m.trim() !== '')
|
|
419
|
+
.map((m) => store.mbSubstr(m.trim(), 0, 160)).slice(0, 800);
|
|
420
|
+
if (clean.length) { modelsFull[store.mbSubstr(prov, 0, 60)] = clean; count++; }
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const workspace = String(body.workspace || '').trim();
|
|
424
|
+
const allowCreate = !!body.allowCreateFolders;
|
|
425
|
+
const agents = (Array.isArray(body.agents) ? body.agents : [])
|
|
426
|
+
.filter((a) => typeof a === 'string' && a.trim() !== '').map((a) => store.mbSubstr(a.trim(), 0, 40)).slice(0, 20);
|
|
427
|
+
const vision = [...new Set((Array.isArray(body.vision) ? body.vision : [])
|
|
428
|
+
.filter((m) => typeof m === 'string' && m.trim() !== '').map((m) => store.mbSubstr(m.trim(), 0, 160)))].slice(0, 800);
|
|
429
|
+
const modelsCtx = {};
|
|
430
|
+
if (body.models_ctx && typeof body.models_ctx === 'object') {
|
|
431
|
+
let count = 0;
|
|
432
|
+
for (const key of Object.keys(body.models_ctx)) {
|
|
433
|
+
if (count >= 2000) break;
|
|
434
|
+
if (typeof key !== 'string' || key.trim() === '') continue;
|
|
435
|
+
const v = parseInt(body.models_ctx[key], 10);
|
|
436
|
+
if (!(v > 0)) continue;
|
|
437
|
+
modelsCtx[store.mbSubstr(key.trim(), 0, 160)] = v;
|
|
438
|
+
count++;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
await store.syncCatalog(folders.slice(0, 200), models.slice(0, 400), workspace, allowCreate, agents, modelsFull, vision, modelsCtx, file);
|
|
442
|
+
return ok({ ok: true, folders: folders.length, models: models.length, models_full: Object.keys(modelsFull).length, agents: agents.length });
|
|
443
|
+
}
|
|
444
|
+
case 'session_import': {
|
|
445
|
+
if (!auth.checkBridgeToken(app, req)) return ok({ ok: false, error: 'Token invalido' }, 401);
|
|
446
|
+
const bridge = reqBridge(req, query);
|
|
447
|
+
const oc = String(body.opencode_session || '').trim();
|
|
448
|
+
if (!/^ses_[A-Za-z0-9]{4,64}$/.test(oc)) return ok({ ok: false, error: 'opencode_session invalido' }, 400);
|
|
449
|
+
const name = store.mbSubstr(String(body.name || '').trim(), 0, 60);
|
|
450
|
+
const folder = store.mbSubstr(String(body.folder || '').trim(), 0, 500);
|
|
451
|
+
const model = String(body.model || '').trim();
|
|
452
|
+
const agent = String(body.agent || 'build').trim();
|
|
453
|
+
const updated = String(body.updated || '').trim();
|
|
454
|
+
let msgs = Array.isArray(body.messages) ? body.messages : [];
|
|
455
|
+
if (msgs.length > 400) msgs = msgs.slice(-400);
|
|
456
|
+
const tokens = Math.max(0, parseInt(body.tokens, 10) || 0);
|
|
457
|
+
const cost = Math.max(0, parseFloat(body.cost) || 0);
|
|
458
|
+
const result = await store.sessionImport(oc, name, folder, model, agent, updated, msgs, tokens, cost, bridge);
|
|
459
|
+
if (!result.ok) return ok(result, 400);
|
|
460
|
+
return ok(result);
|
|
461
|
+
}
|
|
462
|
+
case 'session_tokens': {
|
|
463
|
+
if (!auth.checkBridgeToken(app, req)) return ok({ ok: false, error: 'Token invalido' }, 401);
|
|
464
|
+
const bridge = reqBridge(req, query);
|
|
465
|
+
const oc = String(body.opencode_session || '').trim();
|
|
466
|
+
if (!/^ses_[A-Za-z0-9]{4,64}$/.test(oc)) return ok({ ok: false, error: 'opencode_session invalido' }, 400);
|
|
467
|
+
let folder = String(body.folder || '').trim();
|
|
468
|
+
if (folder !== '' && Array.from(folder).length > 500) folder = '';
|
|
469
|
+
await store.sessionTokens(oc, Math.max(0, parseInt(body.tokens, 10) || 0), Math.max(0, parseFloat(body.cost) || 0), folder, bridge);
|
|
470
|
+
return ok({ ok: true });
|
|
471
|
+
}
|
|
472
|
+
case 'request_folder': {
|
|
473
|
+
if (!auth.requireLogin(app, req, res)) return;
|
|
474
|
+
if (!auth.requireCsrf(app, req, res)) return;
|
|
475
|
+
const bridge = bodyBridge(req, query, body) || await webBridge(req, query);
|
|
476
|
+
const file = paths.bridgeCatalogFile(bridge);
|
|
477
|
+
const cat = await store.catalogRead(file);
|
|
478
|
+
if (!cat.allow_create_folders) return ok({ ok: false, error: 'La creacion de carpetas esta desactivada' }, 403);
|
|
479
|
+
if (cat.workspace === '') return ok({ ok: false, error: 'El puente no definio un espacio de trabajo' }, 400);
|
|
480
|
+
const name = String(body.name || '').trim();
|
|
481
|
+
if (!store.validFolderName(name)) return ok({ ok: false, error: 'Nombre invalido (solo letras, numeros, espacios, - _ . () y 3-50 caracteres)' }, 400);
|
|
482
|
+
const id = await store.catalogAddRequest(name, file);
|
|
483
|
+
return ok({ ok: true, request: { id, name } });
|
|
484
|
+
}
|
|
485
|
+
case 'push_subscribe': {
|
|
486
|
+
if (!auth.requireLogin(app, req, res)) return;
|
|
487
|
+
if (!auth.requireCsrf(app, req, res)) return;
|
|
488
|
+
const endpoint = String(body.endpoint || '').trim();
|
|
489
|
+
const p256dh = String(body.p256dh || '').trim();
|
|
490
|
+
const authKey = String(body.auth || '').trim();
|
|
491
|
+
const ua = store.mbSubstr(String(body.ua || '').trim(), 0, 120);
|
|
492
|
+
if (endpoint === '' || p256dh === '' || authKey === '') return ok({ ok: false, error: 'Faltan datos de la suscripcion' }, 400);
|
|
493
|
+
if (!push.pushEnabled(app)) return ok({ ok: false, error: 'Los avisos push estan desactivados en el servidor' }, 400);
|
|
494
|
+
let host = '';
|
|
495
|
+
try { host = new URL(endpoint).hostname; } catch (e) { host = ''; }
|
|
496
|
+
if (!/^https:\/\//.test(endpoint) || !pushHostAllowed(host)) return ok({ ok: false, error: 'Suscripcion no valida' }, 400);
|
|
497
|
+
await push.pushStore(endpoint, p256dh, authKey, ua);
|
|
498
|
+
return ok({ ok: true });
|
|
499
|
+
}
|
|
500
|
+
case 'push_unsubscribe': {
|
|
501
|
+
if (!auth.requireLogin(app, req, res)) return;
|
|
502
|
+
if (!auth.requireCsrf(app, req, res)) return;
|
|
503
|
+
const endpoint = String(body.endpoint || '').trim();
|
|
504
|
+
if (endpoint !== '') await push.pushRemove(endpoint);
|
|
505
|
+
return ok({ ok: true });
|
|
506
|
+
}
|
|
507
|
+
case 'heartbeat': {
|
|
508
|
+
if (!auth.checkBridgeToken(app, req)) return ok({ ok: false, error: 'Token invalido' }, 401);
|
|
509
|
+
const bridge = reqBridge(req, query);
|
|
510
|
+
const name = headerBridgeName(req);
|
|
511
|
+
let busy = null, busySession = 0;
|
|
512
|
+
if (Object.prototype.hasOwnProperty.call(body, 'busy')) {
|
|
513
|
+
busy = !!body.busy;
|
|
514
|
+
busySession = body.busy ? (parseInt(body.busy_session, 10) || 0) : 0;
|
|
515
|
+
}
|
|
516
|
+
await store.bridgeRegisterFirst(bridge, name);
|
|
517
|
+
await store.bridgeRegistryUpsert(bridge, name, busy, busySession);
|
|
518
|
+
return ok({ ok: true });
|
|
519
|
+
}
|
|
520
|
+
case 'poll': {
|
|
521
|
+
if (!auth.checkBridgeToken(app, req)) return ok({ ok: false, error: 'Token invalido' }, 401);
|
|
522
|
+
const bridge = reqBridge(req, query);
|
|
523
|
+
const file = paths.bridgeCatalogFile(bridge);
|
|
524
|
+
await store.bridgeRegistryUpsert(bridge, headerBridgeName(req));
|
|
525
|
+
const cutoff = Date.now() - store.STALE_PROCESSING_SECONDS * 1000;
|
|
526
|
+
const lite = !!(body.lite || query.get('lite'));
|
|
527
|
+
let waitMax = 5;
|
|
528
|
+
const rw = body.waitMax !== undefined ? body.waitMax : query.get('waitMax');
|
|
529
|
+
if (rw !== undefined && rw !== null && rw !== '') waitMax = Math.max(1, Math.min(20, parseInt(rw, 10) || 5));
|
|
530
|
+
if (!lite && (body.wait || query.get('wait'))) {
|
|
531
|
+
const started = Date.now();
|
|
532
|
+
while (!(await store.pollPeekWork(cutoff, bridge))) {
|
|
533
|
+
await sleep(500);
|
|
534
|
+
if (Date.now() - started >= waitMax * 1000) break;
|
|
535
|
+
if (res.writableEnded) return;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const claimed = [];
|
|
539
|
+
const knownOc = [];
|
|
540
|
+
if (!lite) {
|
|
541
|
+
const sdata = await store.sessionsRead();
|
|
542
|
+
const adopted = [];
|
|
543
|
+
for (const sess of sdata.sessions) {
|
|
544
|
+
if (!(await store.bridgeCanClaimSession(bridge, sess))) continue;
|
|
545
|
+
const mdata = await store.messagesRead(sess.id);
|
|
546
|
+
let changed = false;
|
|
547
|
+
for (const msg of mdata.messages) {
|
|
548
|
+
if (msg.role !== 'user') continue;
|
|
549
|
+
const pending = msg.status === 'pending';
|
|
550
|
+
const processing = msg.status === 'processing' && Date.parse(msg.ts || 0) < cutoff;
|
|
551
|
+
if (!(pending || processing)) continue;
|
|
552
|
+
if (msg.cancel_requested) {
|
|
553
|
+
msg.status = 'canceled';
|
|
554
|
+
msg.canceled_ts = store.nowIso();
|
|
555
|
+
delete msg.cancel_requested;
|
|
556
|
+
changed = true;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
msg.status = 'processing';
|
|
560
|
+
msg.ts = store.nowIso();
|
|
561
|
+
changed = true;
|
|
562
|
+
if (bridge !== '' && store.sessionBridge(sess) === '') {
|
|
563
|
+
sess.bridge = bridge;
|
|
564
|
+
if (!adopted.includes(parseInt(sess.id, 10))) adopted.push(parseInt(sess.id, 10));
|
|
565
|
+
}
|
|
566
|
+
claimed.push({
|
|
567
|
+
session_id: parseInt(sess.id, 10),
|
|
568
|
+
id: parseInt(msg.id, 10),
|
|
569
|
+
text: msg.text,
|
|
570
|
+
img: msg.img !== undefined ? String(msg.img) : null,
|
|
571
|
+
opencode_session: sess.opencode_session || null,
|
|
572
|
+
session: {
|
|
573
|
+
id: parseInt(sess.id, 10),
|
|
574
|
+
name: sess.name,
|
|
575
|
+
folder: sess.folder || '',
|
|
576
|
+
model: sess.model || '',
|
|
577
|
+
agent: sess.agent || 'build',
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
if (changed) await jsonfile.writeAtomic(paths.messagesFile(sess.id), mdata);
|
|
582
|
+
}
|
|
583
|
+
if (adopted.length) {
|
|
584
|
+
await store.sessionsUpdate((sd) => {
|
|
585
|
+
for (const s of sd.sessions) {
|
|
586
|
+
if (adopted.includes(parseInt(s.id, 10)) && store.sessionBridge(s) === '') s.bridge = bridge;
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
const fresh = await store.sessionsRead();
|
|
591
|
+
for (const s of fresh.sessions) {
|
|
592
|
+
if (!s.opencode_session || s.importada) continue;
|
|
593
|
+
const owner = store.sessionBridge(s);
|
|
594
|
+
if (owner === '' || owner === bridge) knownOc.push(String(s.opencode_session));
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
const foldersToCreate = lite ? [] : await store.catalogClaimRequests(file);
|
|
598
|
+
const commands = await store.claimCommands(file);
|
|
599
|
+
return ok({ ok: true, messages: claimed, folders: foldersToCreate, commands, known_oc: knownOc });
|
|
600
|
+
}
|
|
601
|
+
case 'folder_done': {
|
|
602
|
+
if (!auth.checkBridgeToken(app, req)) return ok({ ok: false, error: 'Token invalido' }, 401);
|
|
603
|
+
const file = paths.bridgeCatalogFile(reqBridge(req, query));
|
|
604
|
+
const id = parseInt(body.id, 10) || 0;
|
|
605
|
+
if (id <= 0) return ok({ ok: false, error: 'id invalido' }, 400);
|
|
606
|
+
const folder = body.folder && typeof body.folder === 'object'
|
|
607
|
+
? { name: store.mbSubstr(String(body.folder.name || ''), 0, 60), path: store.mbSubstr(String(body.folder.path || ''), 0, 500) }
|
|
608
|
+
: null;
|
|
609
|
+
await store.catalogFinishRequest(id, !!body.ok, folder, String(body.error || '').trim(), file);
|
|
610
|
+
return ok({ ok: true });
|
|
611
|
+
}
|
|
612
|
+
case 'command_done':
|
|
613
|
+
case 'fs_result':
|
|
614
|
+
case 'proc_result': {
|
|
615
|
+
if (!auth.checkBridgeToken(app, req)) return ok({ ok: false, error: 'Token invalido' }, 401);
|
|
616
|
+
const file = paths.bridgeCatalogFile(reqBridge(req, query));
|
|
617
|
+
const id = parseInt(body.id, 10) || 0;
|
|
618
|
+
if (id <= 0) return ok({ ok: false, error: 'id invalido' }, 400);
|
|
619
|
+
let text = String(body.text || '');
|
|
620
|
+
const max = action === 'command_done' ? 8000 : (action === 'fs_result' ? 700000 : 100000);
|
|
621
|
+
if (Array.from(text).length > max) text = store.mbSubstr(text, 0, max);
|
|
622
|
+
await store.finishCommand(id, !!body.ok, text, String(body.error || ''), file);
|
|
623
|
+
return ok({ ok: true });
|
|
624
|
+
}
|
|
625
|
+
case 'respond_partial': {
|
|
626
|
+
if (!auth.checkBridgeToken(app, req)) return ok({ ok: false, error: 'Token invalido' }, 401);
|
|
627
|
+
const sid = parseInt(body.session_id, 10) || 0;
|
|
628
|
+
const userId = parseInt(body.user_id, 10) || 0;
|
|
629
|
+
const text = String(body.text || '').trim();
|
|
630
|
+
const reasoning = String(body.reasoning || '').trim();
|
|
631
|
+
const ocMsg = String(body.oc_msg || '').trim();
|
|
632
|
+
if (sid <= 0 || userId <= 0) return ok({ ok: false, error: 'session_id y user_id son obligatorios' }, 400);
|
|
633
|
+
if (Array.from(text).length > 50000 || Array.from(reasoning).length > 50000) return ok({ ok: false, error: 'Respuesta demasiado larga' }, 400);
|
|
634
|
+
if (text === '' && reasoning === '') return ok({ ok: false, error: 'Nada para publicar' }, 400);
|
|
635
|
+
await store.messagesUpdate(sid, (data) => {
|
|
636
|
+
let draftIdx = -1;
|
|
637
|
+
for (let i = 0; i < data.messages.length; i++) {
|
|
638
|
+
const m = data.messages[i];
|
|
639
|
+
if (m.role === 'assistant' && m.draft_for && parseInt(m.draft_for, 10) === userId) { draftIdx = i; break; }
|
|
640
|
+
}
|
|
641
|
+
if (draftIdx >= 0) {
|
|
642
|
+
data.messages[draftIdx].text = text;
|
|
643
|
+
if (reasoning !== '') data.messages[draftIdx].reasoning = reasoning;
|
|
644
|
+
if (ocMsg !== '') data.messages[draftIdx].oc_msg = ocMsg;
|
|
645
|
+
data.messages[draftIdx].ts = store.nowIso();
|
|
646
|
+
} else {
|
|
647
|
+
const aid = data.nextId;
|
|
648
|
+
data.nextId = aid + 1;
|
|
649
|
+
const draft = {
|
|
650
|
+
id: aid, role: 'assistant', text, ts: store.nowIso(), status: 'streaming',
|
|
651
|
+
draft_for: userId, agent: store.messageAgentOf(data, userId),
|
|
652
|
+
};
|
|
653
|
+
if (reasoning !== '') draft.reasoning = reasoning;
|
|
654
|
+
if (ocMsg !== '') draft.oc_msg = ocMsg;
|
|
655
|
+
data.messages.push(draft);
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
return ok({ ok: true });
|
|
659
|
+
}
|
|
660
|
+
case 'respond': {
|
|
661
|
+
if (!auth.checkBridgeToken(app, req)) return ok({ ok: false, error: 'Token invalido' }, 401);
|
|
662
|
+
const sid = parseInt(body.session_id, 10) || 0;
|
|
663
|
+
const userId = parseInt(body.user_id, 10) || 0;
|
|
664
|
+
const text = String(body.text || '').trim();
|
|
665
|
+
if (sid <= 0 || userId <= 0 || text === '') return ok({ ok: false, error: 'session_id, user_id y text son obligatorios' }, 400);
|
|
666
|
+
if (Array.from(text).length > 50000) return ok({ ok: false, error: 'Respuesta demasiado larga' }, 400);
|
|
667
|
+
const exists = await store.getSession(sid);
|
|
668
|
+
if (!exists) return ok({ ok: false, error: 'Sesion no encontrada' }, 404);
|
|
669
|
+
const oc = body.opencode_session ? String(body.opencode_session).trim() : '';
|
|
670
|
+
const reasoning = String(body.reasoning || '').trim();
|
|
671
|
+
const ocMsg = String(body.oc_msg || '').trim();
|
|
672
|
+
const clearSession = !!body.clear_session;
|
|
673
|
+
const canceled = !!body.canceled;
|
|
674
|
+
await store.sessionsUpdate((sd) => {
|
|
675
|
+
const i = sd.sessions.findIndex((s) => parseInt(s.id, 10) === sid);
|
|
676
|
+
if (i < 0) return false;
|
|
677
|
+
if (clearSession) sd.sessions[i].opencode_session = null;
|
|
678
|
+
else if (oc !== '' && (sd.sessions[i].opencode_session || null) !== oc) {
|
|
679
|
+
const taken = sd.sessions.some((s2, j) => j !== i && s2.opencode_session && String(s2.opencode_session) === oc);
|
|
680
|
+
if (!taken) sd.sessions[i].opencode_session = oc;
|
|
681
|
+
}
|
|
682
|
+
sd.sessions[i].last_ts = store.nowIso();
|
|
683
|
+
});
|
|
684
|
+
const sdata = await store.sessionsRead();
|
|
685
|
+
const sess = sdata.sessions.find((s) => parseInt(s.id, 10) === sid);
|
|
686
|
+
let aid = null;
|
|
687
|
+
const found = await store.messagesUpdate(sid, (data) => {
|
|
688
|
+
let msgFound = false;
|
|
689
|
+
let author = '';
|
|
690
|
+
for (const msg of data.messages) {
|
|
691
|
+
if (parseInt(msg.id, 10) === userId) {
|
|
692
|
+
msg.status = 'done';
|
|
693
|
+
msg.answered_ts = store.nowIso();
|
|
694
|
+
delete msg.cancel_requested;
|
|
695
|
+
if (typeof msg.author === 'string') author = msg.author;
|
|
696
|
+
msgFound = true;
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (!msgFound) return false;
|
|
701
|
+
let draftIdx = -1;
|
|
702
|
+
for (let i = 0; i < data.messages.length; i++) {
|
|
703
|
+
const m = data.messages[i];
|
|
704
|
+
if (m.role === 'assistant' && m.draft_for && parseInt(m.draft_for, 10) === userId) { draftIdx = i; break; }
|
|
705
|
+
}
|
|
706
|
+
if (draftIdx >= 0) {
|
|
707
|
+
const m = data.messages[draftIdx];
|
|
708
|
+
aid = parseInt(m.id, 10);
|
|
709
|
+
m.text = text;
|
|
710
|
+
m.status = 'done';
|
|
711
|
+
m.answered_ts = store.nowIso();
|
|
712
|
+
m.ts = store.nowIso();
|
|
713
|
+
if (reasoning !== '') m.reasoning = reasoning; else delete m.reasoning;
|
|
714
|
+
if (ocMsg !== '') m.oc_msg = ocMsg;
|
|
715
|
+
if (canceled) m.canceled = true; else delete m.canceled;
|
|
716
|
+
if (!m.agent) m.agent = store.messageAgentOf(data, userId, (sess && sess.agent) || '');
|
|
717
|
+
if (author && !m.author) m.author = author;
|
|
718
|
+
delete m.draft_for;
|
|
719
|
+
} else {
|
|
720
|
+
aid = data.nextId;
|
|
721
|
+
data.nextId = aid + 1;
|
|
722
|
+
const nm = { id: aid, role: 'assistant', text, ts: store.nowIso(), status: 'done', agent: store.messageAgentOf(data, userId, (sess && sess.agent) || '') };
|
|
723
|
+
if (reasoning !== '') nm.reasoning = reasoning;
|
|
724
|
+
if (ocMsg !== '') nm.oc_msg = ocMsg;
|
|
725
|
+
if (canceled) nm.canceled = true;
|
|
726
|
+
if (author) nm.author = author;
|
|
727
|
+
data.messages.push(nm);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
if (found === false) return ok({ ok: false, error: 'Mensaje no encontrado' }, 404);
|
|
731
|
+
push.pushSend(app, (canceled ? '\u23f9 ' : '') + 'IA respondio \u00b7 ' + ((sess && sess.name) || 'chat'), store.mbSubstr(text, 0, 200) + (Array.from(text).length > 200 ? '.' : ''), 'chat.php?session=' + sid).catch(() => {});
|
|
732
|
+
return ok({ ok: true, id: aid });
|
|
733
|
+
}
|
|
734
|
+
case 'browse': {
|
|
735
|
+
if (!auth.requireLogin(app, req, res)) return;
|
|
736
|
+
const file = paths.bridgeCatalogFile(await webBridge(req, query));
|
|
737
|
+
const ws = await store.catalogWorkspaceRoot(file);
|
|
738
|
+
if (ws === '') return ok({ ok: false, error: 'El puente no ha definido un workspace' }, 400);
|
|
739
|
+
const rel = String(query.get('path') || '');
|
|
740
|
+
const result = await store.workspaceList(rel, file);
|
|
741
|
+
if (result === null) return ok({ ok: false, error: 'Ruta invalida o fuera del workspace' }, 400);
|
|
742
|
+
return ok({ ok: true, workspace: ws, path: result.path, entries: result.entries });
|
|
743
|
+
}
|
|
744
|
+
case 'read_file': {
|
|
745
|
+
if (!auth.requireLogin(app, req, res)) return;
|
|
746
|
+
const file = paths.bridgeCatalogFile(await webBridge(req, query));
|
|
747
|
+
const rel = String(query.get('path') || '');
|
|
748
|
+
const abs = await store.safeJoinWorkspace(rel, 4, file);
|
|
749
|
+
if (!abs) return ok({ ok: false, error: 'Archivo no encontrado' }, 404);
|
|
750
|
+
let st;
|
|
751
|
+
try { st = await fsp.stat(abs); } catch (e) { return ok({ ok: false, error: 'Archivo no encontrado' }, 404); }
|
|
752
|
+
if (!st.isFile()) return ok({ ok: false, error: 'Archivo no encontrado' }, 404);
|
|
753
|
+
const size = st.size;
|
|
754
|
+
const ext = path.extname(abs).toLowerCase().replace(/^\./, '');
|
|
755
|
+
const mimes = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif' };
|
|
756
|
+
if (mimes[ext]) {
|
|
757
|
+
if (size <= 0 || size > 6 * 1024 * 1024) return ok({ ok: false, error: 'Imagen demasiado grande (>6 MB) para previsualizar' }, 400);
|
|
758
|
+
const bin = await fsp.readFile(abs);
|
|
759
|
+
return ok({ ok: true, path: rel, size, mtime: Math.floor(st.mtimeMs / 1000), kind: 'image', mime: mimes[ext], url: 'data:' + mimes[ext] + ';base64,' + bin.toString('base64') });
|
|
760
|
+
}
|
|
761
|
+
const content = await store.readTextFile(abs);
|
|
762
|
+
if (content === null) return ok({ ok: false, error: 'Binario o demasiado grande (>512 KB)' }, 400);
|
|
763
|
+
return ok({ ok: true, path: rel, size, mtime: Math.floor(st.mtimeMs / 1000), kind: 'text', content });
|
|
764
|
+
}
|
|
765
|
+
case 'run_oc': {
|
|
766
|
+
const s = auth.requireCsrf(app, req, res);
|
|
767
|
+
if (!s) return;
|
|
768
|
+
const bridge = bodyBridge(req, query, body) || await webBridge(req, query);
|
|
769
|
+
const file = paths.bridgeCatalogFile(bridge);
|
|
770
|
+
const cmd = String(body.cmd || '').toLowerCase().replace(/[^a-z_]/g, '');
|
|
771
|
+
const args = Array.isArray(body.args) ? body.args : [];
|
|
772
|
+
if (!Object.prototype.hasOwnProperty.call(OC_ALLOWED, cmd)) return ok({ ok: false, error: 'Comando no permitido' }, 400);
|
|
773
|
+
if (OC_MUTATING.has(cmd) && s.role !== 'admin') return ok({ ok: false, error: 'Permiso insuficiente' }, 403);
|
|
774
|
+
const spec = OC_ALLOWED[cmd];
|
|
775
|
+
const cleanArgs = [];
|
|
776
|
+
for (let i = 0; i < args.length; i++) {
|
|
777
|
+
const tipo = spec[i] || 'arg';
|
|
778
|
+
if (ocArgValido(tipo, args[i])) cleanArgs.push(String(args[i]));
|
|
779
|
+
}
|
|
780
|
+
if (spec.length && cleanArgs.length !== Math.min(args.length, spec.length)) return ok({ ok: false, error: 'Argumento invalido' }, 400);
|
|
781
|
+
const id = await store.enqueueCommand(cmd, cleanArgs, file);
|
|
782
|
+
return ok({ ok: true, id });
|
|
783
|
+
}
|
|
784
|
+
case 'oc_command_status': {
|
|
785
|
+
if (!auth.requireLogin(app, req, res)) return;
|
|
786
|
+
const id = parseInt(query.get('id') || '0', 10) || 0;
|
|
787
|
+
const file = paths.bridgeCatalogFile(await webBridge(req, query));
|
|
788
|
+
const cat = await store.catalogRead(file);
|
|
789
|
+
for (const c of (cat.commands || [])) {
|
|
790
|
+
if (parseInt(c.id, 10) === id) {
|
|
791
|
+
return ok({ ok: true, status: c.status, result: c.result !== undefined ? c.result : null, error: c.error || '' });
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return ok({ ok: false, error: 'no existe' }, 404);
|
|
795
|
+
}
|
|
796
|
+
case 'oc_sessions': {
|
|
797
|
+
if (!auth.requireLogin(app, req, res)) return;
|
|
798
|
+
return ok({ ok: true, sessions: await store.ocSessionsList() });
|
|
799
|
+
}
|
|
800
|
+
case 'oc_session_attach': {
|
|
801
|
+
if (!auth.requireLogin(app, req, res)) return;
|
|
802
|
+
if (!auth.requireCsrf(app, req, res)) return;
|
|
803
|
+
const oc = String(body.opencode_session || '').trim();
|
|
804
|
+
const name = String(body.name || '').trim();
|
|
805
|
+
const folder = String(body.folder || '');
|
|
806
|
+
const model = String(body.model || '');
|
|
807
|
+
const agent = String(body.agent || 'build');
|
|
808
|
+
const bridge = bodyBridge(req, query, body) || await webBridge(req, query);
|
|
809
|
+
const file = paths.bridgeCatalogFile(bridge);
|
|
810
|
+
if (oc === '') return ok({ ok: false, error: 'opencode_session requerido' }, 400);
|
|
811
|
+
if (folder !== '' && (await store.folderPathInCatalog(folder, file)) === null) return ok({ ok: false, error: 'Carpeta no disponible' }, 400);
|
|
812
|
+
if (model !== '' && !(await store.modelInCatalog(model, file))) return ok({ ok: false, error: 'Modelo no disponible' }, 400);
|
|
813
|
+
if (agent !== '' && !(await store.agentInCatalog(agent, file))) return ok({ ok: false, error: 'Agente no disponible' }, 400);
|
|
814
|
+
const id = await store.addSession(name !== '' ? name : 'Opencode ' + oc.slice(0, 8), folder, model, agent, oc, bridge);
|
|
815
|
+
return ok({ ok: true, session: await store.getSession(id) });
|
|
816
|
+
}
|
|
817
|
+
case 'search_index': {
|
|
818
|
+
if (!auth.requireLogin(app, req, res)) return;
|
|
819
|
+
const q = String(query.get('q') || '');
|
|
820
|
+
return ok({ ok: true, results: await store.searchIndexBuild(q) });
|
|
821
|
+
}
|
|
822
|
+
case 'stream': {
|
|
823
|
+
if (!auth.requireLogin(app, req, res)) return;
|
|
824
|
+
return handleStream({ app, req, res });
|
|
825
|
+
}
|
|
826
|
+
default:
|
|
827
|
+
return ok({ ok: false, error: 'Accion no valida' }, 400);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function pushHostAllowed(host) {
|
|
832
|
+
const fixed = ['fcm.googleapis.com', 'android.googleapis.com', 'push.services.mozilla.com', 'updates.push.services.mozilla.com', 'web.push.apple.com'];
|
|
833
|
+
if (fixed.includes(host)) return true;
|
|
834
|
+
const h = String(host).toLowerCase();
|
|
835
|
+
return ['.notify.windows.com', '.googleapis.com', '.mozilla.com'].some((s) => h.endsWith(s));
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
839
|
+
|
|
840
|
+
async function handleStream({ app, req, res }) {
|
|
841
|
+
res.writeHead(200, {
|
|
842
|
+
'Content-Type': 'text/event-stream',
|
|
843
|
+
'Cache-Control': 'no-cache',
|
|
844
|
+
'Connection': 'keep-alive',
|
|
845
|
+
'X-Accel-Buffering': 'no',
|
|
846
|
+
});
|
|
847
|
+
const send = (event, data) => {
|
|
848
|
+
if (res.writableEnded) return;
|
|
849
|
+
res.write('event: ' + event + '\n');
|
|
850
|
+
res.write('data: ' + JSON.stringify(data) + '\n\n');
|
|
851
|
+
};
|
|
852
|
+
send('hello', { now: store.nowIso() });
|
|
853
|
+
const started = Date.now();
|
|
854
|
+
let lastRegRaw = '', lastBusySig = '', lastSessionsSig = '', lastMsgScanSec = 0, lastMsgMaxTs = 0, lastNotify = 0, loop = 0;
|
|
855
|
+
const lastCatSigs = {};
|
|
856
|
+
let timer = null;
|
|
857
|
+
const stop = () => { if (timer) { clearInterval(timer); timer = null; } };
|
|
858
|
+
req.on('close', stop);
|
|
859
|
+
timer = setInterval(async () => {
|
|
860
|
+
if (res.writableEnded) { stop(); return; }
|
|
861
|
+
if (Date.now() - started >= 25000) { send('bye', { ts: store.nowIso() }); stop(); res.end(); return; }
|
|
862
|
+
try {
|
|
863
|
+
let regRaw = '';
|
|
864
|
+
try { regRaw = await fsp.readFile(paths.bridgesFile(), 'utf8'); } catch (e) { /* aun no */ }
|
|
865
|
+
if (regRaw !== lastRegRaw) {
|
|
866
|
+
lastRegRaw = regRaw;
|
|
867
|
+
let reg = null;
|
|
868
|
+
try { reg = JSON.parse(regRaw); } catch (e) { /* nada */ }
|
|
869
|
+
if (reg && reg.bridges && Object.keys(reg.bridges).length) {
|
|
870
|
+
const sum = await store.bridgesSummary();
|
|
871
|
+
const busySig = store.md5(sum.map((b) => b.id + '=' + (b.busy_session || '')).join('|'));
|
|
872
|
+
send('online', { ts: store.nowIso(), bridges: sum });
|
|
873
|
+
if (busySig !== lastBusySig) { lastBusySig = busySig; send('sessions_changed', { ts: store.nowIso(), busy: true }); }
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
let files = [];
|
|
877
|
+
try { files = await fsp.readdir(paths.dataDir()); } catch (e) { /* nada */ }
|
|
878
|
+
for (const f of files) {
|
|
879
|
+
if (!/^catalog.*\.json$/.test(f)) continue;
|
|
880
|
+
const full = path.join(paths.dataDir(), f);
|
|
881
|
+
let raw = '';
|
|
882
|
+
try { raw = await fsp.readFile(full, 'utf8'); } catch (e) { continue; }
|
|
883
|
+
const sig = store.md5(raw);
|
|
884
|
+
const id = f === 'catalog.json' ? '' : f.replace(/^catalog-/, '').replace(/\.json$/, '');
|
|
885
|
+
if (!(full in lastCatSigs)) { lastCatSigs[full] = sig; continue; }
|
|
886
|
+
if (lastCatSigs[full] !== sig) { lastCatSigs[full] = sig; send('catalog', { bridge: id }); }
|
|
887
|
+
}
|
|
888
|
+
let sraw = '';
|
|
889
|
+
try { sraw = await fsp.readFile(paths.sessionsFile(), 'utf8'); } catch (e) { /* nada */ }
|
|
890
|
+
const ssig = store.md5(sraw);
|
|
891
|
+
if (ssig !== lastSessionsSig) { lastSessionsSig = ssig; send('sessions_changed', { ts: store.nowIso() }); }
|
|
892
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
893
|
+
if (nowSec !== lastMsgScanSec) {
|
|
894
|
+
lastMsgScanSec = nowSec;
|
|
895
|
+
let maxTs = 0;
|
|
896
|
+
for (const f of files) {
|
|
897
|
+
if (!/^messages-.*\.json$/.test(f)) continue;
|
|
898
|
+
try {
|
|
899
|
+
const st = await fsp.stat(path.join(paths.dataDir(), f));
|
|
900
|
+
if (st.mtimeMs > maxTs) maxTs = st.mtimeMs;
|
|
901
|
+
} catch (e) { /* nada */ }
|
|
902
|
+
}
|
|
903
|
+
if (maxTs !== lastMsgMaxTs) {
|
|
904
|
+
lastMsgMaxTs = maxTs;
|
|
905
|
+
if (nowSec - lastNotify >= 2) { lastNotify = nowSec; send('sessions_changed', { ts: store.nowIso(), msgs: true }); }
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
} catch (e) { /* el stream sigue */ }
|
|
909
|
+
if ((++loop % 10) === 0) send('ping', { ts: store.nowIso() });
|
|
910
|
+
}, 500);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
module.exports = {
|
|
914
|
+
handleApi, handleChat, handleLogin, handleLoginPage, handleLogout, handleIndex,
|
|
915
|
+
};
|