@danieltmn/openbridge 0.2.0 → 0.4.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/src/web/routes.js CHANGED
@@ -18,6 +18,15 @@ const jsonfile = require('../store/jsonfile');
18
18
 
19
19
  const TEMPLATES = path.join(__dirname, 'templates');
20
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
+
21
30
  async function renderTpl(name, vars) {
22
31
  const tpl = await fsp.readFile(path.join(TEMPLATES, name), 'utf8');
23
32
  return tpl.replace(/\{\{([A-Z_]+)\}\}/g, (m, k) => (
@@ -59,6 +68,12 @@ async function loginCsrf(req, res) {
59
68
  return csrf;
60
69
  }
61
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
+
62
77
  async function handleLoginPage({ app, req, res, query }) {
63
78
  if (auth.readSession(app, req)) {
64
79
  res.writeHead(302, { Location: 'chat.php' });
@@ -71,6 +86,7 @@ async function handleLoginPage({ app, req, res, query }) {
71
86
  THEMES_JSON: JSON.stringify(store.themesIndex()),
72
87
  ERROR_BLOCK: '',
73
88
  CSRF: csrf,
89
+ USER_PREFILL: lastUser(req),
74
90
  });
75
91
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
76
92
  res.end(html);
@@ -87,25 +103,34 @@ async function handleLogin({ app, req, res, query, form }) {
87
103
  THEMES_JSON: JSON.stringify(store.themesIndex()),
88
104
  ERROR_BLOCK: '<div class="error">\u26a0 ' + msg.replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c])) + '</div>',
89
105
  CSRF: csrf,
106
+ USER_PREFILL: lastUser(req) || String(form.username || '').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 32),
90
107
  });
91
108
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
92
109
  res.end(html);
93
110
  };
94
111
  if (!csrfOk) return renderError('Sesion expirada, recarga la pagina.');
95
- const locked = auth.loginLockRemaining(req);
112
+ const username = String(form.username || '').trim();
113
+ const locked = auth.loginLockRemaining(req, username);
96
114
  if (locked > 0) {
97
115
  const mins = Math.max(1, Math.ceil(locked / 60));
98
116
  return renderError('Demasiados intentos fallidos. Proba de nuevo en ' + mins + ' min.');
99
117
  }
100
118
  const password = String(form.password || '');
101
- if (config.verifyPassword(password, app.password)) {
102
- auth.loginClear(req);
103
- auth.startSession(app, req, res, app.username);
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);
104
129
  res.writeHead(302, { Location: 'chat.php' });
105
130
  return res.end();
106
131
  }
107
- auth.loginRecordFailure(req);
108
- return renderError('Contrasena incorrecta.');
132
+ auth.loginRecordFailure(req, username);
133
+ return renderError('Usuario o contrasena incorrectos.');
109
134
  }
110
135
 
111
136
  async function handleChat({ app, req, res, query }) {
@@ -122,6 +147,8 @@ async function handleChat({ app, req, res, query }) {
122
147
  const html = await renderTpl('chat.html', {
123
148
  THEME: pickTheme(req, query, known),
124
149
  CSRF: session.c,
150
+ USER_NAME: session.name,
151
+ USER_ROLE: session.role,
125
152
  INITIAL_SESSION: initial > 0 ? String(initial) : 'null',
126
153
  THEMES_JSON: JSON.stringify(store.themesIndex()),
127
154
  SSE_DISABLED: 'false',
@@ -171,6 +198,9 @@ const OC_ALLOWED = {
171
198
  opencode_version: [],
172
199
  fs_list: ['path'],
173
200
  fs_read: ['path'],
201
+ git_status: ['path'],
202
+ git_diff: ['path'],
203
+ git_checkout: ['path', 'path'],
174
204
  tunnel_start: ['arg'],
175
205
  tunnel_stop: ['arg'],
176
206
  tunnel_list: [],
@@ -256,8 +286,9 @@ async function handleApi(ctx) {
256
286
  return ok({ ok: true, session: await store.getSession(id) });
257
287
  }
258
288
  case 'session_delete': {
259
- if (!auth.requireLogin(app, req, res)) return;
260
- if (!auth.requireCsrf(app, req, res)) return;
289
+ const s = auth.requireCsrf(app, req, res);
290
+ if (!s) return;
291
+ if (s.role !== 'admin') return ok({ ok: false, error: 'Permiso insuficiente' }, 403);
261
292
  const id = parseInt(body.id, 10) || 0;
262
293
  if (id <= 0) return ok({ ok: false, error: 'id invalido' }, 400);
263
294
  await store.deleteSession(id);
@@ -336,8 +367,8 @@ async function handleApi(ctx) {
336
367
  return ok({ ok: true, cancel: false });
337
368
  }
338
369
  case 'send': {
339
- if (!auth.requireLogin(app, req, res)) return;
340
- if (!auth.requireCsrf(app, req, res)) return;
370
+ const s = auth.requireCsrf(app, req, res);
371
+ if (!s) return;
341
372
  const sid = parseInt(body.session, 10) || 0;
342
373
  const sess = sid > 0 ? await store.getSession(sid) : null;
343
374
  if (!sess) return ok({ ok: false, error: 'Sesion no encontrada' }, 404);
@@ -349,7 +380,7 @@ async function handleApi(ctx) {
349
380
  }
350
381
  if (text === '' && img === '') return ok({ ok: false, error: 'Mensaje vacio' }, 400);
351
382
  if (Array.from(text).length > 10000) return ok({ ok: false, error: 'Mensaje demasiado largo' }, 400);
352
- const extra = { agent: String(sess.agent || 'build') };
383
+ const extra = { agent: String(sess.agent || 'build'), author: s.name };
353
384
  if (img !== '') extra.img = img;
354
385
  const id = await store.addMessage(sid, 'user', text, 'pending', extra);
355
386
  if (store.sessionHasDefaultName(sess)) {
@@ -649,11 +680,13 @@ async function handleApi(ctx) {
649
680
  let aid = null;
650
681
  const found = await store.messagesUpdate(sid, (data) => {
651
682
  let msgFound = false;
683
+ let author = '';
652
684
  for (const msg of data.messages) {
653
685
  if (parseInt(msg.id, 10) === userId) {
654
686
  msg.status = 'done';
655
687
  msg.answered_ts = store.nowIso();
656
688
  delete msg.cancel_requested;
689
+ if (typeof msg.author === 'string') author = msg.author;
657
690
  msgFound = true;
658
691
  break;
659
692
  }
@@ -674,6 +707,7 @@ async function handleApi(ctx) {
674
707
  if (reasoning !== '') m.reasoning = reasoning; else delete m.reasoning;
675
708
  if (canceled) m.canceled = true; else delete m.canceled;
676
709
  if (!m.agent) m.agent = store.messageAgentOf(data, userId, (sess && sess.agent) || '');
710
+ if (author && !m.author) m.author = author;
677
711
  delete m.draft_for;
678
712
  } else {
679
713
  aid = data.nextId;
@@ -681,6 +715,7 @@ async function handleApi(ctx) {
681
715
  const nm = { id: aid, role: 'assistant', text, ts: store.nowIso(), status: 'done', agent: store.messageAgentOf(data, userId, (sess && sess.agent) || '') };
682
716
  if (reasoning !== '') nm.reasoning = reasoning;
683
717
  if (canceled) nm.canceled = true;
718
+ if (author) nm.author = author;
684
719
  data.messages.push(nm);
685
720
  }
686
721
  });
@@ -720,13 +755,14 @@ async function handleApi(ctx) {
720
755
  return ok({ ok: true, path: rel, size, mtime: Math.floor(st.mtimeMs / 1000), kind: 'text', content });
721
756
  }
722
757
  case 'run_oc': {
723
- if (!auth.requireLogin(app, req, res)) return;
724
- if (!auth.requireCsrf(app, req, res)) return;
758
+ const s = auth.requireCsrf(app, req, res);
759
+ if (!s) return;
725
760
  const bridge = bodyBridge(req, query, body) || await webBridge(req, query);
726
761
  const file = paths.bridgeCatalogFile(bridge);
727
762
  const cmd = String(body.cmd || '').toLowerCase().replace(/[^a-z_]/g, '');
728
763
  const args = Array.isArray(body.args) ? body.args : [];
729
764
  if (!Object.prototype.hasOwnProperty.call(OC_ALLOWED, cmd)) return ok({ ok: false, error: 'Comando no permitido' }, 400);
765
+ if (OC_MUTATING.has(cmd) && s.role !== 'admin') return ok({ ok: false, error: 'Permiso insuficiente' }, 403);
730
766
  const spec = OC_ALLOWED[cmd];
731
767
  const cleanArgs = [];
732
768
  for (let i = 0; i < args.length; i++) {
package/src/web/server.js CHANGED
@@ -70,6 +70,13 @@ function securityHeaders(res) {
70
70
  res.setHeader('X-Content-Type-Options', 'nosniff');
71
71
  res.setHeader('Referrer-Policy', 'no-referrer');
72
72
  res.setHeader('X-Frame-Options', 'DENY');
73
+ // La app usa scripts/estilos inline (templates) y data: para imagenes
74
+ // adjuntas. Igual bloquea origenes externos, frames y objetos.
75
+ res.setHeader('Content-Security-Policy',
76
+ "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
77
+ + "script-src 'self' 'unsafe-inline'; connect-src 'self'; worker-src 'self'; "
78
+ + "manifest-src 'self'; object-src 'none'; base-uri 'none'; "
79
+ + "frame-ancestors 'none'; form-action 'self'");
73
80
  }
74
81
 
75
82
  async function handle(app, req, res) {
@@ -280,6 +280,11 @@
280
280
  text-align: center;
281
281
  border: 1px solid var(--border);
282
282
  }
283
+ #sidebar .footer .userbadge {
284
+ flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
285
+ color: var(--muted); font-size: 12px;
286
+ }
287
+ #sidebar .footer .userbadge.admin { color: var(--accent); }
283
288
  #scrim {
284
289
  display: none;
285
290
  position: fixed; inset: 0;
@@ -768,6 +773,17 @@
768
773
  .slash-panel .item.sel, .slash-panel .item:hover { background: var(--accent-soft); }
769
774
  .slash-panel .item b { color: var(--mine); font-weight: 600; }
770
775
  .slash-panel .item span { display: block; color: var(--muted); font-size: 11px; margin-top: 1px; }
776
+ .tpl-panel .tpl-row { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; cursor: pointer; font-size: 13.5px; }
777
+ .tpl-panel .tpl-row:hover { background: var(--accent-soft); }
778
+ .tpl-panel .tpl-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
779
+ .tpl-panel .tpl-del { background: transparent; border: 0; color: var(--muted); cursor: pointer; font: inherit; padding: 2px 6px; border-radius: 6px; flex-shrink: 0; }
780
+ .tpl-panel .tpl-del:hover { color: var(--danger); }
781
+ .tpl-panel .tpl-empty { color: var(--muted); font-size: 12px; padding: 8px 10px; line-height: 1.4; }
782
+ .tpl-panel .tpl-save {
783
+ display: block; width: 100%; margin-top: 6px; padding: 8px; border: 1px dashed var(--border);
784
+ border-radius: 8px; background: transparent; color: var(--muted); font: inherit; font-size: 12.5px; cursor: pointer;
785
+ }
786
+ .tpl-panel .tpl-save:hover { color: var(--accent); border-color: var(--accent); }
771
787
  form.sendbar button {
772
788
  padding: 9px 16px; border: 1px solid var(--mine); border-radius: 8px; background: transparent; color: var(--mine);
773
789
  font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer;
@@ -958,6 +974,33 @@
958
974
  color: var(--muted); font-size: 13px; padding: 24px 0; text-align: center;
959
975
  }
960
976
 
977
+ /* ----- Cambios (git) ----- */
978
+ .chg-files { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin: 0 0 14px; }
979
+ .chg-row { display: grid; grid-template-columns: 34px 1fr auto; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 13px; align-items: center; }
980
+ .chg-row:last-child { border-bottom: 0; }
981
+ .chg-rev {
982
+ background: transparent; border: 1px solid var(--border); color: var(--muted);
983
+ border-radius: 6px; width: 26px; height: 24px; line-height: 1; cursor: pointer; font: inherit; font-size: 12px;
984
+ }
985
+ .chg-rev:hover { color: var(--danger); border-color: var(--danger); }
986
+ .chg-rev:disabled { opacity: .4; cursor: not-allowed; }
987
+ .chg-st { font-weight: 700; text-align: center; font-size: 11px; color: var(--muted); }
988
+ .chg-st.mod { color: var(--warn-text, #d29922); }
989
+ .chg-st.add { color: var(--accent); }
990
+ .chg-st.del { color: var(--danger); }
991
+ .chg-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
992
+ .diff {
993
+ background: var(--code-bg, var(--bg)); border: 1px solid var(--border);
994
+ border-radius: 8px; overflow: auto; padding: 10px 0; margin: 0 0 20px;
995
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
996
+ font-size: 12px; line-height: 1.5;
997
+ }
998
+ .diff-line { white-space: pre; padding: 0 12px; }
999
+ .diff-line.add { background: rgba(63, 185, 80, .12); color: #3fb950; }
1000
+ .diff-line.del { background: rgba(248, 81, 73, .12); color: #f85149; }
1001
+ .diff-line.hunk { color: var(--accent); }
1002
+ .diff-line.head { color: var(--muted); }
1003
+
961
1004
  .view-head { margin-bottom: 12px; }
962
1005
  .view-head h2 { margin: 0 0 4px; font-size: 16px; }
963
1006
  .view-sub { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -1159,13 +1202,17 @@
1159
1202
  <button data-view="search" type="button" title="Buscar">buscar</button>
1160
1203
  <button data-view="preview" type="button" title="Vista previa del proyecto (túnel)">preview</button>
1161
1204
  <button data-view="procs" type="button" title="Procesos de la sesión">procs</button>
1205
+ <button data-view="changes" type="button" title="Cambios del proyecto (git)">cambios</button>
1162
1206
  </div>
1163
1207
  <div class="footer">
1164
1208
  <div class="row" id="bridgeStatusRow">
1165
1209
  <span class="dot" id="bridgeDot"></span>
1166
1210
  <span id="bridgeStatusText">puente: desconocido</span>
1167
1211
  </div>
1168
- <a class="logout" href="logout.php">salir</a>
1212
+ <div class="row">
1213
+ <span class="userbadge" id="userBadge" title="sesión actual">{{USER_NAME}}</span>
1214
+ <a class="logout" href="logout.php">salir</a>
1215
+ </div>
1169
1216
  </div>
1170
1217
  <div class="resizer" id="sbResizer" title="Arrastrá para ajustar · doble clic para restablecer"></div>
1171
1218
  </aside>
@@ -1221,11 +1268,13 @@
1221
1268
  </div>
1222
1269
  <span class="prompt">❯</span>
1223
1270
  <textarea id="input" rows="1" placeholder="escribí un mensaje…" autocomplete="off"></textarea>
1271
+ <button type="button" class="imgbtn" id="btnTpl" title="Plantillas de prompts" aria-label="Plantillas de prompts"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"/></svg></button>
1224
1272
  <button type="button" class="imgbtn" id="btnImg" title="Adjuntar imagen (modelos con visión)" aria-label="Adjuntar imagen"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/></svg></button>
1225
1273
  <button type="button" class="imgbtn" id="btnMic" title="Dictar por voz" aria-label="Dictar por voz"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z"/></svg></button>
1226
1274
  <input type="file" id="imgInput" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none">
1227
1275
  <button type="submit" id="sendBtn">enviar</button>
1228
1276
  <div id="slashPanel" class="slash-panel"></div>
1277
+ <div id="tplPanel" class="slash-panel tpl-panel"></div>
1229
1278
  </form>
1230
1279
  </div>
1231
1280
 
@@ -1235,6 +1284,7 @@
1235
1284
  <div id="viewHistory" class="view" style="display:none"></div>
1236
1285
  <div id="viewPreview" class="view" style="display:none"></div>
1237
1286
  <div id="viewProcs" class="view" style="display:none"></div>
1287
+ <div id="viewChanges" class="view" style="display:none"></div>
1238
1288
 
1239
1289
  <footer id="statusbar">
1240
1290
  <span class="dot" id="sbDot"></span>
@@ -1302,7 +1352,9 @@
1302
1352
  themes: {{THEMES_JSON}},
1303
1353
  sseDisabled: {{SSE_DISABLED}},
1304
1354
  pushEnabled: {{PUSH_ENABLED}},
1305
- pushKey: "{{PUSH_KEY}}"
1355
+ pushKey: "{{PUSH_KEY}}",
1356
+ userName: "{{USER_NAME}}",
1357
+ userRole: "{{USER_ROLE}}"
1306
1358
  };
1307
1359
  </script>
1308
1360
  <script src="app.js?v={{APPJS_VER}}" defer></script>
@@ -111,7 +111,11 @@
111
111
  <input type="hidden" name="csrf" value="{{CSRF}}">
112
112
  <label class="field">
113
113
  <span class="prompt">❯</span>
114
- <input type="password" id="password" name="password" placeholder="contraseña" required autofocus autocomplete="current-password">
114
+ <input type="text" id="username" name="username" placeholder="usuario" required autofocus autocomplete="username" value="{{USER_PREFILL}}">
115
+ </label>
116
+ <label class="field">
117
+ <span class="prompt">❯</span>
118
+ <input type="password" id="password" name="password" placeholder="contraseña" required autocomplete="current-password">
115
119
  <button type="button" class="peek" id="btnPeek" aria-label="Mostrar u ocultar contraseña" tabindex="-1">👁</button>
116
120
  </label>
117
121
  <label class="keep" for="keep">