@danieltmn/openbridge 0.3.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/CHANGELOG.md +20 -0
- package/README.md +107 -32
- package/docs/ARCHITECTURE.md +49 -8
- package/package.json +1 -1
- package/src/auth.js +44 -23
- package/src/cli.js +106 -7
- package/src/config.js +74 -4
- package/src/web/assets/app.js +68 -44
- package/src/web/assets/manifest.webmanifest +2 -0
- package/src/web/assets/sw.js +1 -1
- package/src/web/routes.js +46 -13
- package/src/web/server.js +7 -0
- package/src/web/templates/chat.html +12 -2
- package/src/web/templates/login.html +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ Todos los cambios relevantes de OpenBridge. Formato basado en
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y
|
|
5
5
|
[Versionado Semantico](https://semver.org/lang/es/).
|
|
6
6
|
|
|
7
|
+
## [0.4.0] - 2026-09-14
|
|
8
|
+
|
|
9
|
+
### Agregado
|
|
10
|
+
|
|
11
|
+
- **Multiusuario** con roles `admin`/`user`: login con usuario + contrasena,
|
|
12
|
+
sesion ligada al usuario y a la version de contrasena, rate limit por
|
|
13
|
+
IP+usuario, y comando CLI `users`
|
|
14
|
+
(`list`/`add`/`remove`/`passwd`/`role`/`disable`/`enable`); `passwd --user`.
|
|
15
|
+
- Gate server-side: `session_delete` y los comandos mutantes del puente
|
|
16
|
+
(procesos, tuneles, revertir) solo para `admin`; la web oculta esos controles.
|
|
17
|
+
- **Autor** en cada mensaje (auditoria) y muestra del usuario en el sidebar.
|
|
18
|
+
- **Content-Security-Policy** y `Secure` de cookie solo desde loopback
|
|
19
|
+
(`trust proxy` explicito).
|
|
20
|
+
|
|
21
|
+
### Cambiado
|
|
22
|
+
|
|
23
|
+
- La sesion se invalida al cambiar la contrasena (`pv`); los usuarios
|
|
24
|
+
deshabilitados no pueden entrar.
|
|
25
|
+
- `app.json` migra el `username`/`password` legado a `users[]`.
|
|
26
|
+
|
|
7
27
|
## [0.3.0] - 2026-09-13
|
|
8
28
|
|
|
9
29
|
### Cambios
|
package/README.md
CHANGED
|
@@ -44,9 +44,9 @@ openbridge server
|
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
Al arrancar (queda en segundo plano) imprime el **estado con la URL pública**
|
|
47
|
-
(`https://….tunnelmole.net/chat.php`): abríla desde el celular y logueate con
|
|
48
|
-
usuario
|
|
49
|
-
|
|
47
|
+
(`https://….tunnelmole.net/chat.php`): abríla desde el celular y logueate con tu
|
|
48
|
+
usuario y contraseña. Para correrlo en primer plano usá
|
|
49
|
+
`openbridge server --stream`; para detenerlo, `openbridge stop`.
|
|
50
50
|
|
|
51
51
|
### Opciones de `init`
|
|
52
52
|
|
|
@@ -55,7 +55,8 @@ primer plano usá `openbridge server --stream`; para detenerlo, `openbridge stop
|
|
|
55
55
|
--name "PC 1" nombre visible de esta computadora
|
|
56
56
|
--id pc1 identificador corto (default: derivado del nombre)
|
|
57
57
|
--port 8799 puerto local
|
|
58
|
-
--password <clave> contraseña
|
|
58
|
+
--password <clave> contraseña del admin inicial
|
|
59
|
+
--user <nombre> nombre del admin inicial (default: admin)
|
|
59
60
|
--tunnel <prov> tunnelmole | ngrok | cloudflare | none
|
|
60
61
|
--domain <host> dominio fijo del túnel (ngrok)
|
|
61
62
|
--yes sin preguntas (usa defaults)
|
|
@@ -63,12 +64,25 @@ primer plano usá `openbridge server --stream`; para detenerlo, `openbridge stop
|
|
|
63
64
|
--dir <ruta> casa portable (default: directorio actual)
|
|
64
65
|
```
|
|
65
66
|
|
|
67
|
+
## Actualizar
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm i -g @danieltmn/openbridge@latest # o: npx @danieltmn/openbridge@latest …
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
La configuración y los datos en `.openbridge/` se **migran solos**: el layout
|
|
74
|
+
viejo (archivos sueltos) pasa a `.openbridge/`, y el `username`/`password` de una
|
|
75
|
+
instalación de un solo usuario se convierte en el **primer admin** de `users[]`
|
|
76
|
+
(con la misma contraseña). Por el cambio de formato de sesión, **hay que volver a
|
|
77
|
+
iniciar sesión** una vez; los chats, carpetas, túnel y push se conservan.
|
|
78
|
+
|
|
66
79
|
## Comandos
|
|
67
80
|
|
|
68
81
|
| Comando | Qué hace |
|
|
69
82
|
|---|---|
|
|
70
83
|
| `openbridge init` | Configura la casa (workspace, contraseña, túnel, puerto) |
|
|
71
|
-
| `openbridge passwd` | Cambia la contraseña de acceso (detiene el server si corre) |
|
|
84
|
+
| `openbridge passwd` | Cambia la contraseña de acceso (`--user <nombre>`; detiene el server si corre) |
|
|
85
|
+
| `openbridge users` | Usuarios y roles: `list`, `add`, `remove`, `passwd`, `role`, `disable`, `enable` |
|
|
72
86
|
| `openbridge server` | Arranca app + puente + túnel en **segundo plano** y muestra el estado (`--stream` = primer plano) |
|
|
73
87
|
| `openbridge stop` | Detiene el server y su árbol de procesos |
|
|
74
88
|
| `openbridge status` | Estado, URL, puente en línea y chats |
|
|
@@ -89,7 +103,7 @@ Todo vive dentro de **`.openbridge/`** en el directorio donde corrés `init`
|
|
|
89
103
|
```
|
|
90
104
|
.openbridge/
|
|
91
105
|
config.json config del puente (apiUrl, token, workspace, bridgeId…)
|
|
92
|
-
app.json config de la app (
|
|
106
|
+
app.json config de la app (usuarios, token, VAPID, puerto, túnel)
|
|
93
107
|
folders.json lista blanca de carpetas que se ven desde el celular
|
|
94
108
|
data/ sesiones, mensajes, catálogo, registro de puentes, push
|
|
95
109
|
logs/ server.log y bridge.log
|
|
@@ -99,47 +113,108 @@ Movés la carpeta a donde quieras y sigue funcionando (es portable). Si venías
|
|
|
99
113
|
de una versión anterior con los archivos sueltos, se **migran solos** a
|
|
100
114
|
`.openbridge/` la primera vez que corras un comando.
|
|
101
115
|
|
|
102
|
-
## Varias computadoras
|
|
116
|
+
## Varias computadoras (hub + remotas)
|
|
117
|
+
|
|
118
|
+
La PC **hub** corre la app y el túnel; las demás PCs corren solo el **puente** y
|
|
119
|
+
apuntan al hub. En el sidebar de la web elegís qué PC usar y ves sus proyectos y
|
|
120
|
+
chats.
|
|
121
|
+
|
|
122
|
+
Ejemplo concreto:
|
|
123
|
+
|
|
124
|
+
1. En el **hub** (tiene la app y la URL pública):
|
|
125
|
+
```bash
|
|
126
|
+
openbridge init
|
|
127
|
+
openbridge server # muestra estado, URL y QR
|
|
128
|
+
openbridge status # volvés a ver la URL cuando quieras
|
|
129
|
+
```
|
|
130
|
+
2. Copiá el **token del puente** del hub: `.openbridge/app.json` → `bridgeToken`.
|
|
131
|
+
3. En la **PC 2** (remota):
|
|
132
|
+
```bash
|
|
133
|
+
openbridge init --tunnel none # no necesita túnel propio
|
|
134
|
+
openbridge bridge --api https://tu-url-publica/api.php \
|
|
135
|
+
--token <bridgeToken> --id pc2 --name "PC 2"
|
|
136
|
+
```
|
|
137
|
+
4. Abrí la URL del hub desde el celular: en el sidebar aparecen **PC 1** y
|
|
138
|
+
**PC 2** para alternar.
|
|
139
|
+
|
|
140
|
+
El puente remoto solo necesita salida a internet hacia el hub; no abre puertos ni
|
|
141
|
+
túnel propio. Para que arranque solo en cada PC: `openbridge autostart install`.
|
|
103
142
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
- En la PC "hub": `openbridge init` + `openbridge server` (queda la app + túnel).
|
|
107
|
-
- En otra PC: `openbridge init` y luego `openbridge bridge --api <url-del-hub>
|
|
108
|
-
--token <token> --id pc2 --name "PC 2"` (sin editar `config.json` a mano).
|
|
143
|
+
## Túnel y URL estable
|
|
109
144
|
|
|
110
|
-
|
|
145
|
+
Proveedores (`--tunnel` o `openbridge tunnel <prov>`): `tunnelmole` (gratis, sin
|
|
146
|
+
cuenta), `ngrok` (cuenta + authtoken; admite **dominio fijo**) y `cloudflare`
|
|
147
|
+
(quick tunnel). Ver/cambiar sin reconfigurar todo:
|
|
111
148
|
|
|
112
|
-
|
|
149
|
+
```bash
|
|
150
|
+
openbridge tunnel # estado actual
|
|
151
|
+
openbridge tunnel ngrok --domain mi-pc.ngrok.app # URL fija (recomendado)
|
|
152
|
+
openbridge stop && openbridge server # reiniciar para aplicar
|
|
153
|
+
```
|
|
113
154
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
atado al origen). Para PWA/push estables conviene ngrok con dominio fijo o
|
|
119
|
-
Cloudflare con dominio propio.
|
|
155
|
+
Con URL **aleatoria** la PWA y el push no persisten entre reinicios (están atados
|
|
156
|
+
al origen). Para una URL estable usá ngrok con dominio fijo (o un named tunnel de
|
|
157
|
+
Cloudflare). Escaneá el QR de `openbridge status`/`openbridge qr` para abrirla en
|
|
158
|
+
el celular.
|
|
120
159
|
|
|
121
160
|
## Avisos push
|
|
122
161
|
|
|
123
|
-
Las claves VAPID se generan solas en `init`. Con URL de túnel
|
|
124
|
-
suscripción
|
|
125
|
-
|
|
162
|
+
Las claves VAPID se generan solas en `init`. Con URL de túnel aleatoria la
|
|
163
|
+
suscripción no persiste entre reinicios (el push está atado al origen); la app la
|
|
164
|
+
vuelve a registrar cuando la abrís en la URL nueva. Para que sea estable, usá una
|
|
165
|
+
URL fija (ngrok con `--domain`).
|
|
126
166
|
|
|
127
167
|
## Seguridad
|
|
128
168
|
|
|
129
|
-
- La app escucha solo en `127.0.0.1
|
|
130
|
-
- Login con contraseña (scrypt)
|
|
131
|
-
|
|
169
|
+
- La app escucha **solo en `127.0.0.1`**; el túnel la expone a internet.
|
|
170
|
+
- Login con **usuario y contraseña** (scrypt), cookie firmada `HttpOnly` +
|
|
171
|
+
`SameSite=Lax`, **CSRF** y **rate limit** (5 intentos / 15 min por IP+usuario);
|
|
172
|
+
token del puente autogenerado.
|
|
173
|
+
- La cookie usa `Secure` cuando el pedido llega por HTTPS **desde loopback** (el
|
|
174
|
+
túnel); no se confía en `X-Forwarded-Proto` de otros orígenes.
|
|
175
|
+
- Respuestas con `Content-Security-Policy` (orígenes externos, frames y objetos
|
|
176
|
+
bloqueados), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` y
|
|
177
|
+
`Referrer-Policy: no-referrer`.
|
|
132
178
|
- El túnel es **público mientras corre**: detenelo (`openbridge stop`) cuando no
|
|
133
|
-
lo uses.
|
|
179
|
+
lo uses y mantené la contraseña fuerte.
|
|
180
|
+
|
|
181
|
+
## Usuarios y roles
|
|
182
|
+
|
|
183
|
+
Cada persona entra con **usuario y contraseña**. Hay dos roles:
|
|
184
|
+
|
|
185
|
+
- **admin**: todo (crear/borrar chats, revertir cambios, procesos, túneles y
|
|
186
|
+
administrar usuarios).
|
|
187
|
+
- **user**: chat, archivos, cambios y búsqueda; no puede borrar chats, revertir
|
|
188
|
+
cambios ni correr procesos/túneles.
|
|
189
|
+
|
|
190
|
+
Se administran desde la PC con la CLI (los cambios requieren reiniciar el server):
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
openbridge users list
|
|
194
|
+
openbridge users add ana --role user --password <clave>
|
|
195
|
+
openbridge users passwd ana
|
|
196
|
+
openbridge users role ana admin
|
|
197
|
+
openbridge users disable ana # enable para reactivar
|
|
198
|
+
openbridge users remove ana
|
|
199
|
+
openbridge passwd --user admin # cambia la clave de un usuario
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`init --user <nombre>` crea el admin inicial. La contraseña se guarda con scrypt,
|
|
203
|
+
y cambiar una clave **invalida las sesiones abiertas** de ese usuario. El último
|
|
204
|
+
admin no se puede borrar, degradar ni deshabilitar. Cada mensaje guarda **quién
|
|
205
|
+
lo envió** (se ve en el chat).
|
|
134
206
|
|
|
135
207
|
## Estado
|
|
136
208
|
|
|
137
209
|
Proyecto en desarrollo. Ya funciona: `init`, `server` (segundo plano; `--stream`
|
|
138
|
-
en primer plano), `stop`, `status`, `logs` (`--follow`), `bridge`
|
|
139
|
-
`passwd`, `import`, `reset`, `autostart`, `doctor
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
210
|
+
en primer plano), `stop`, `status`, `qr`, `tunnel`, `logs` (`--follow`), `bridge`
|
|
211
|
+
(con flags), `passwd`, `users`, `import`, `reset`, `autostart`, `doctor`; login
|
|
212
|
+
**multiusuario** con roles `admin`/`user` y rate limit; API completa y SSE;
|
|
213
|
+
catálogo por PC; Web Push; túnel (TunnelMole/ngrok/cloudflare); y en la web: chat
|
|
214
|
+
con streaming, adjuntar imagen, dictado por voz, plantillas de prompts,
|
|
215
|
+
tokens/contexto y **costo** por sesión, vista de archivos, vista de **cambios**
|
|
216
|
+
(git status/diff) con **revertir**, búsqueda global, sesiones de opencode y
|
|
217
|
+
**autor** en cada mensaje. Tests en `npm test` (28).
|
|
143
218
|
|
|
144
219
|
## Licencia
|
|
145
220
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -19,18 +19,20 @@ el estado); `--stream` lo deja en primer plano.
|
|
|
19
19
|
| Ruta | Responsabilidad |
|
|
20
20
|
|---|---|
|
|
21
21
|
| `bin/openbridge.js` | Entrada CLI + guard de Node. |
|
|
22
|
-
| `src/cli.js` | Comandos (`init`, `passwd`, `server`, `stop`, `status`, `logs`, `bridge`, `import`, `reset`, `autostart`, `doctor`). |
|
|
22
|
+
| `src/cli.js` | Comandos (`init`, `passwd`, `users`, `server`, `stop`, `status`, `qr`, `tunnel`, `logs`, `bridge`, `import`, `reset`, `autostart`, `doctor`). |
|
|
23
23
|
| `src/paths.js` | Casa portable: ubicacion de `config.json`, `app.json`, `data/`, `logs/`. |
|
|
24
|
-
| `src/config.js` | Lectura/escritura de config, hash scrypt, VAPID. |
|
|
25
|
-
| `src/auth.js` | Cookie de sesion firmada, CSRF, remember-me, rate limit, token del puente. |
|
|
24
|
+
| `src/config.js` | Lectura/escritura de config, hash scrypt, **usuarios/roles**, VAPID. |
|
|
25
|
+
| `src/auth.js` | Cookie de sesion firmada, CSRF, remember-me, **roles**, rate limit, token del puente. |
|
|
26
26
|
| `src/log.js` | Log a consola + `logs/server.log`. |
|
|
27
27
|
| `src/push.js` | Web Push (VAPID + `web-push`). |
|
|
28
|
+
| `src/qr.js` | Generador QR propio (terminal/SVG), sin dependencias. |
|
|
28
29
|
| `src/tunnel/` | Proveedores de tunel enchufables. |
|
|
29
|
-
| `src/web/server.js` | Servidor HTTP, estaticos, seguridad. |
|
|
30
|
-
| `src/web/routes.js` | Router de paginas y API (`?action=...`), compatible con `app.js`/`bridge.js`. |
|
|
30
|
+
| `src/web/server.js` | Servidor HTTP, estaticos, cabeceras de seguridad (CSP). |
|
|
31
|
+
| `src/web/routes.js` | Router de paginas y API (`?action=...`), gate de roles, compatible con `app.js`/`bridge.js`. |
|
|
31
32
|
| `src/web/assets/` | Frontend (`app.js`, temas, iconos, service worker, manifest). |
|
|
32
33
|
| `src/store/` | Store JSON con mutex por archivo y escritura atomica. |
|
|
33
|
-
| `src/bridge/bridge.js` | Puente: sincroniza catalogo, hace poll y ejecuta opencode. |
|
|
34
|
+
| `src/bridge/bridge.js` | Puente: sincroniza catalogo, hace poll y ejecuta opencode; comandos fs/proc/tunel/git. |
|
|
35
|
+
| `scripts/release.mjs` | Release local (fuera del paquete npm): version, changelog, tag, push y publish. |
|
|
34
36
|
|
|
35
37
|
## Casa portable
|
|
36
38
|
|
|
@@ -53,7 +55,8 @@ OpenBridge. La CLI pasa la **base** (`--dir`/cwd) al puente via
|
|
|
53
55
|
|
|
54
56
|
## Flujo de un mensaje
|
|
55
57
|
|
|
56
|
-
1. El celular envia `?action=send` (con CSRF) -> `src/web/routes.js
|
|
58
|
+
1. El celular envia `?action=send` (con CSRF) -> `src/web/routes.js`; el mensaje
|
|
59
|
+
se guarda con el **autor** de la sesion.
|
|
57
60
|
2. El mensaje queda `pending` en `data/messages-<id>.json`.
|
|
58
61
|
3. El puente hace `poll`, reclama el mensaje y corre
|
|
59
62
|
`opencode run --model <m> [--session <id>]`.
|
|
@@ -66,12 +69,50 @@ El hub corre la app; cada PC corre `openbridge bridge` apuntando al hub
|
|
|
66
69
|
(`apiUrl` + `apiToken` + `bridgeId`). El catalogo se guarda por PC
|
|
67
70
|
(`data/catalog-<id>.json`) y el sidebar permite elegir que PC usar.
|
|
68
71
|
|
|
72
|
+
## Usuarios y roles
|
|
73
|
+
|
|
74
|
+
`app.json` guarda `users[]` (`id`, `name`, `role`, `password` scrypt, `pv`,
|
|
75
|
+
`disabled`). El modelo viejo de un `username`/`password` se migra solo a
|
|
76
|
+
`users[0]` (admin).
|
|
77
|
+
|
|
78
|
+
- La sesion (cookie firmada) guarda el `id` del usuario y su `pv`; el **rol se lee
|
|
79
|
+
siempre del server** en cada request. Cambiar la contrasena sube `pv` e invalida
|
|
80
|
+
las sesiones abiertas; un usuario `disabled` no entra.
|
|
81
|
+
- Roles: `admin` (todo) y `user` (chat/lectura). El gate es **server-side** en
|
|
82
|
+
`routes.js`; la UI solo oculta los controles.
|
|
83
|
+
- Rate limit de login por IP+usuario y error generico (no filtra si el usuario
|
|
84
|
+
existe; corre scrypt igual contra un hash dummy).
|
|
85
|
+
- Cada mensaje guarda `author` (quien lo envio); la respuesta hereda el autor del
|
|
86
|
+
pedido.
|
|
87
|
+
|
|
88
|
+
## Comandos del puente
|
|
89
|
+
|
|
90
|
+
La web encola comandos (`?action=run_oc`) que el puente reclama por el poll y
|
|
91
|
+
resuelve (los resultados vuelven por `command_done`/`fs_result`). Sirve tanto en
|
|
92
|
+
local como en remoto:
|
|
93
|
+
|
|
94
|
+
- Lectura: `models`, `session_list`, `session_info`, `opencode_version`,
|
|
95
|
+
`fs_list`, `fs_read`, `proc_list`, `proc_log`, `tunnel_list`, `git_status`,
|
|
96
|
+
`git_diff`.
|
|
97
|
+
- Mutantes (solo `admin`): `proc_start`, `proc_stop`, `tunnel_start`,
|
|
98
|
+
`tunnel_stop`, `git_checkout`.
|
|
99
|
+
|
|
100
|
+
Las rutas se validan contra el workspace (`procResolveFolder`/`resolveInWorkspace`)
|
|
101
|
+
y los argumentos con una whitelist (`OC_ALLOWED`).
|
|
102
|
+
|
|
69
103
|
## Decisiones
|
|
70
104
|
|
|
71
105
|
- **Stateless sessions**: la cookie se firma con HMAC; no hay store de sesiones.
|
|
106
|
+
El `id`+`pv` del usuario van en el payload y el rol se resuelve en cada request.
|
|
107
|
+
- **Multiusuario con roles**: `admin`/`user`, gate server-side; el ultimo admin no
|
|
108
|
+
se puede borrar, degradar ni deshabilitar.
|
|
72
109
|
- **Escritura atomica + mutex**: `tmp` + `rename` y una cola por archivo evitan
|
|
73
110
|
corrupciones con polls concurrentes.
|
|
74
111
|
- **Sin dependencias de framework**: HTTP nativo de Node; la unica dependencia
|
|
75
|
-
es `web-push`.
|
|
112
|
+
es `web-push`. El QR se genera con codigo propio (`src/qr.js`).
|
|
113
|
+
- **Cabeceras de seguridad**: CSP restrictiva; permite scripts/estilos inline
|
|
114
|
+
porque los templates los usan.
|
|
115
|
+
- **Cola de comandos del puente**: un solo canal (poll) para fs, procesos,
|
|
116
|
+
tuneles y git, valido igual en local y remoto.
|
|
76
117
|
- **Proveedores de tunel detras de una interfaz**: `startTunnel(port, provider)`
|
|
77
118
|
devuelve `{ url, provider, pid, stop }`.
|
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -38,8 +38,16 @@ function parseCookies(header) {
|
|
|
38
38
|
return out;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Detras del tunel el server recibe los pedidos desde loopback (el tunel corre
|
|
42
|
+
// en la misma maquina y reenvia a 127.0.0.1). Solo en ese caso confiamos en
|
|
43
|
+
// X-Forwarded-Proto: un cliente que llegara directo por la red no puede forzar
|
|
44
|
+
// el flag Secure de la cookie. Sin info de socket (tests), se confia.
|
|
41
45
|
function isSecure(req) {
|
|
42
|
-
|
|
46
|
+
if (req.socket && req.socket.encrypted) return true;
|
|
47
|
+
const remote = req.socket && req.socket.remoteAddress;
|
|
48
|
+
const loopback = !remote || remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
|
|
49
|
+
if (!loopback) return false;
|
|
50
|
+
const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
|
|
43
51
|
return proto === 'https';
|
|
44
52
|
}
|
|
45
53
|
|
|
@@ -51,10 +59,10 @@ function serializeCookie(name, value, req, maxAge) {
|
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
// ---------------------------------------------------------------------------
|
|
54
|
-
// Sesion
|
|
62
|
+
// Sesion (ligada al id del usuario; el rol se lee siempre del server)
|
|
55
63
|
// ---------------------------------------------------------------------------
|
|
56
|
-
function makeSession(app,
|
|
57
|
-
const payload = b64url(JSON.stringify({ u:
|
|
64
|
+
function makeSession(app, user, csrf, exp) {
|
|
65
|
+
const payload = b64url(JSON.stringify({ u: user.id, pv: parseInt(user.pv, 10) || 1, c: csrf, e: exp }));
|
|
58
66
|
return payload + '.' + sign(payload, app.csrfSecret);
|
|
59
67
|
}
|
|
60
68
|
function readSession(app, req) {
|
|
@@ -68,8 +76,11 @@ function readSession(app, req) {
|
|
|
68
76
|
if (!safeEqual(sign(payload, app.csrfSecret), sig)) return null;
|
|
69
77
|
let data;
|
|
70
78
|
try { data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch (e) { return null; }
|
|
71
|
-
if (!data ||
|
|
72
|
-
|
|
79
|
+
if (!data || typeof data.e !== 'number' || data.e < Math.floor(Date.now() / 1000)) return null;
|
|
80
|
+
const user = config.userById(app, data.u);
|
|
81
|
+
if (!user || user.disabled) return null;
|
|
82
|
+
if ((parseInt(user.pv, 10) || 1) !== (parseInt(data.pv, 10) || 1)) return null;
|
|
83
|
+
return { id: user.id, name: user.name, role: user.role, u: user.id, c: data.c, e: data.e };
|
|
73
84
|
}
|
|
74
85
|
function currentCsrf(app, req) {
|
|
75
86
|
const s = readSession(app, req);
|
|
@@ -80,6 +91,14 @@ function requireLogin(app, req, res) {
|
|
|
80
91
|
if (!s) { json(res, 401, { ok: false, error: 'No autorizado' }); return null; }
|
|
81
92
|
return s;
|
|
82
93
|
}
|
|
94
|
+
// Exige que el usuario tenga uno de los roles indicados.
|
|
95
|
+
function requireRole(app, req, res, roles) {
|
|
96
|
+
const s = readSession(app, req);
|
|
97
|
+
if (!s) { json(res, 401, { ok: false, error: 'No autorizado' }); return null; }
|
|
98
|
+
const allowed = Array.isArray(roles) ? roles : [roles];
|
|
99
|
+
if (!allowed.includes(s.role)) { json(res, 403, { ok: false, error: 'Permiso insuficiente' }); return null; }
|
|
100
|
+
return s;
|
|
101
|
+
}
|
|
83
102
|
function requireCsrf(app, req, res) {
|
|
84
103
|
const s = readSession(app, req);
|
|
85
104
|
const given = String(req.headers['x-csrf'] || '').trim();
|
|
@@ -90,13 +109,13 @@ function requireCsrf(app, req, res) {
|
|
|
90
109
|
return s;
|
|
91
110
|
}
|
|
92
111
|
|
|
93
|
-
function startSession(app, req, res,
|
|
112
|
+
function startSession(app, req, res, user) {
|
|
94
113
|
const csrf = crypto.randomBytes(16).toString('hex');
|
|
95
114
|
const exp = Math.floor(Date.now() / 1000) + REMEMBER_DAYS * 86400;
|
|
96
|
-
const token = makeSession(app,
|
|
115
|
+
const token = makeSession(app, user, csrf, exp);
|
|
97
116
|
res.setHeader('Set-Cookie', [
|
|
98
117
|
serializeCookie(SESSION_COOKIE, token, req, REMEMBER_DAYS * 86400),
|
|
99
|
-
serializeCookie(REMEMBER_COOKIE, makeRemember(app,
|
|
118
|
+
serializeCookie(REMEMBER_COOKIE, makeRemember(app, user), req, REMEMBER_DAYS * 86400),
|
|
100
119
|
]);
|
|
101
120
|
return csrf;
|
|
102
121
|
}
|
|
@@ -107,9 +126,9 @@ function endSession(req, res) {
|
|
|
107
126
|
]);
|
|
108
127
|
}
|
|
109
128
|
|
|
110
|
-
function makeRemember(app,
|
|
129
|
+
function makeRemember(app, user) {
|
|
111
130
|
const exp = Math.floor(Date.now() / 1000) + REMEMBER_DAYS * 86400;
|
|
112
|
-
const payload =
|
|
131
|
+
const payload = user.id + '|' + exp + '|' + (parseInt(user.pv, 10) || 1);
|
|
113
132
|
return b64url(payload) + '.' + sign(payload, app.csrfSecret);
|
|
114
133
|
}
|
|
115
134
|
// Re-autentica desde la cookie remember si la sesion se perdio.
|
|
@@ -125,10 +144,12 @@ function rememberAutoLogin(app, req, res) {
|
|
|
125
144
|
let plain;
|
|
126
145
|
try { plain = Buffer.from(payload, 'base64url').toString('utf8'); } catch (e) { return false; }
|
|
127
146
|
const parts = plain.split('|');
|
|
128
|
-
if (parts.length !==
|
|
129
|
-
const [
|
|
130
|
-
if (
|
|
147
|
+
if (parts.length !== 3) return false;
|
|
148
|
+
const [uid, exp, pv] = parts;
|
|
149
|
+
if (!/^\d+$/.test(exp) || parseInt(exp, 10) < Math.floor(Date.now() / 1000)) return false;
|
|
131
150
|
if (!safeEqual(sign(payload, app.csrfSecret), sig)) return false;
|
|
151
|
+
const user = config.userById(app, uid);
|
|
152
|
+
if (!user || user.disabled || (parseInt(user.pv, 10) || 1) !== (parseInt(pv, 10) || 1)) return false;
|
|
132
153
|
startSession(app, req, res, user);
|
|
133
154
|
return true;
|
|
134
155
|
}
|
|
@@ -141,10 +162,10 @@ const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
|
|
141
162
|
const LOGIN_LOCK_MS = 15 * 60 * 1000;
|
|
142
163
|
const loginAttempts = new Map();
|
|
143
164
|
|
|
144
|
-
function clientKey(req) {
|
|
165
|
+
function clientKey(req, user) {
|
|
145
166
|
const xff = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
|
146
167
|
const ip = xff || (req.socket && req.socket.remoteAddress) || 'desconocido';
|
|
147
|
-
return String(ip);
|
|
168
|
+
return String(ip) + '|' + String(user || '').trim().toLowerCase();
|
|
148
169
|
}
|
|
149
170
|
function pruneLoginAttempts(now) {
|
|
150
171
|
for (const [key, st] of loginAttempts) {
|
|
@@ -152,25 +173,25 @@ function pruneLoginAttempts(now) {
|
|
|
152
173
|
}
|
|
153
174
|
}
|
|
154
175
|
// Segundos restantes de bloqueo (0 = puede intentar).
|
|
155
|
-
function loginLockRemaining(req) {
|
|
176
|
+
function loginLockRemaining(req, user) {
|
|
156
177
|
const now = Date.now();
|
|
157
178
|
pruneLoginAttempts(now);
|
|
158
|
-
const st = loginAttempts.get(clientKey(req));
|
|
179
|
+
const st = loginAttempts.get(clientKey(req, user));
|
|
159
180
|
if (!st || !st.lockUntil) return 0;
|
|
160
181
|
const left = st.lockUntil - now;
|
|
161
182
|
return left > 0 ? Math.ceil(left / 1000) : 0;
|
|
162
183
|
}
|
|
163
|
-
function loginRecordFailure(req) {
|
|
184
|
+
function loginRecordFailure(req, user) {
|
|
164
185
|
const now = Date.now();
|
|
165
|
-
const key = clientKey(req);
|
|
186
|
+
const key = clientKey(req, user);
|
|
166
187
|
let st = loginAttempts.get(key);
|
|
167
188
|
if (!st || (!st.lockUntil && now - st.first > LOGIN_WINDOW_MS)) st = { count: 0, first: now, lockUntil: 0 };
|
|
168
189
|
st.count++;
|
|
169
190
|
if (st.count >= LOGIN_MAX_ATTEMPTS) st.lockUntil = now + LOGIN_LOCK_MS;
|
|
170
191
|
loginAttempts.set(key, st);
|
|
171
192
|
}
|
|
172
|
-
function loginClear(req) {
|
|
173
|
-
loginAttempts.delete(clientKey(req));
|
|
193
|
+
function loginClear(req, user) {
|
|
194
|
+
loginAttempts.delete(clientKey(req, user));
|
|
174
195
|
}
|
|
175
196
|
|
|
176
197
|
// ---------------------------------------------------------------------------
|
|
@@ -197,7 +218,7 @@ function json(res, code, data) {
|
|
|
197
218
|
module.exports = {
|
|
198
219
|
SESSION_COOKIE, REMEMBER_COOKIE,
|
|
199
220
|
parseCookies, serializeCookie, isSecure,
|
|
200
|
-
makeSession, readSession, currentCsrf, requireLogin, requireCsrf,
|
|
221
|
+
makeSession, readSession, currentCsrf, requireLogin, requireRole, requireCsrf,
|
|
201
222
|
startSession, endSession, rememberAutoLogin,
|
|
202
223
|
loginLockRemaining, loginRecordFailure, loginClear,
|
|
203
224
|
checkBridgeToken, safeEqual, json,
|
package/src/cli.js
CHANGED
|
@@ -183,6 +183,12 @@ async function cmdInit(argv) {
|
|
|
183
183
|
console.log('Usa --force para reconfigurar (se pisan config.json/app.json).');
|
|
184
184
|
return 0;
|
|
185
185
|
}
|
|
186
|
+
if (paths.exists() && flags.force) {
|
|
187
|
+
const prev = config.readApp();
|
|
188
|
+
if ((prev.users || []).length > 1) {
|
|
189
|
+
console.log('aviso: --force deja un unico usuario admin; se pierden los demas usuarios.');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
186
192
|
|
|
187
193
|
if (stopRunningServer()) {
|
|
188
194
|
console.log('Habia un server corriendo; lo detuve para reconfigurar (reinicialo con `openbridge server`).');
|
|
@@ -235,12 +241,12 @@ async function cmdInit(argv) {
|
|
|
235
241
|
bridgeId: id,
|
|
236
242
|
bridgeName: name,
|
|
237
243
|
};
|
|
244
|
+
const adminName = flags.user || 'admin';
|
|
238
245
|
const appCfg = {
|
|
239
246
|
...config.DEFAULT_APP,
|
|
240
247
|
port,
|
|
241
248
|
host: '127.0.0.1',
|
|
242
|
-
|
|
243
|
-
password: config.hashPassword(password),
|
|
249
|
+
users: [config.makeUser(adminName, password, 'admin')],
|
|
244
250
|
csrfSecret,
|
|
245
251
|
bridgeToken,
|
|
246
252
|
vapid,
|
|
@@ -259,7 +265,7 @@ async function cmdInit(argv) {
|
|
|
259
265
|
console.log(' workspace : ' + workspace);
|
|
260
266
|
console.log(' puente : ' + id + ' (' + name + ')');
|
|
261
267
|
console.log(' URL local : http://127.0.0.1:' + port + '/chat.php');
|
|
262
|
-
console.log(' usuario : ' +
|
|
268
|
+
console.log(' usuario : ' + adminName);
|
|
263
269
|
console.log(' contrasena: ' + password + (generated ? ' (generada)' : ''));
|
|
264
270
|
console.log(' tunel : ' + provider);
|
|
265
271
|
console.log(' carpetas : ' + folders.length + ' en folders.json');
|
|
@@ -693,23 +699,114 @@ async function cmdPasswd(argv) {
|
|
|
693
699
|
return 1;
|
|
694
700
|
}
|
|
695
701
|
const app = config.readApp();
|
|
702
|
+
const name = String(flags.user || (app.users[0] && app.users[0].name) || 'admin');
|
|
703
|
+
const user = config.findUser(app, name);
|
|
704
|
+
if (!user) {
|
|
705
|
+
console.error('Usuario no encontrado: ' + name);
|
|
706
|
+
return 1;
|
|
707
|
+
}
|
|
696
708
|
const interactive = !flags.yes && process.stdin.isTTY;
|
|
697
709
|
let password = flags.password || '';
|
|
698
|
-
if (!password && interactive) password = await askHidden('Nueva contrasena (enter = generar): ');
|
|
710
|
+
if (!password && interactive) password = await askHidden('Nueva contrasena para "' + name + '" (enter = generar): ');
|
|
699
711
|
let generated = false;
|
|
700
712
|
if (!password) {
|
|
701
713
|
password = config.randomToken(12);
|
|
702
714
|
generated = true;
|
|
703
715
|
}
|
|
704
716
|
if (stopRunningServer()) console.log('Habia un server corriendo; lo detuve para aplicar el cambio.');
|
|
705
|
-
|
|
717
|
+
user.password = config.hashPassword(password);
|
|
718
|
+
user.pv = (parseInt(user.pv, 10) || 1) + 1; // invalida las sesiones abiertas
|
|
706
719
|
config.writeApp(app);
|
|
707
|
-
console.log('Contrasena actualizada para "' +
|
|
720
|
+
console.log('Contrasena actualizada para "' + user.name + '".');
|
|
708
721
|
console.log(' contrasena: ' + password + (generated ? ' (generada)' : ''));
|
|
709
722
|
console.log(' volve a arrancar: openbridge server');
|
|
710
723
|
return 0;
|
|
711
724
|
}
|
|
712
725
|
|
|
726
|
+
// ---------------------------------------------------------------------------
|
|
727
|
+
// users: administracion de usuarios y roles (solo desde la CLI local)
|
|
728
|
+
// ---------------------------------------------------------------------------
|
|
729
|
+
async function cmdUsers(argv) {
|
|
730
|
+
const { flags, _ } = parseArgs(argv);
|
|
731
|
+
if (!paths.exists()) {
|
|
732
|
+
console.error('No hay configuracion en ' + paths.home() + '. Corre primero: openbridge init');
|
|
733
|
+
return 1;
|
|
734
|
+
}
|
|
735
|
+
const app = config.readApp();
|
|
736
|
+
const sub = String(_[0] || 'list').toLowerCase();
|
|
737
|
+
const name = _[1] || flags.name;
|
|
738
|
+
|
|
739
|
+
const persist = (msg) => {
|
|
740
|
+
if (stopRunningServer()) console.log('Habia un server corriendo; lo detuve para aplicar el cambio.');
|
|
741
|
+
config.writeApp(app);
|
|
742
|
+
console.log(msg);
|
|
743
|
+
console.log(' volve a arrancar: openbridge server');
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
if (sub === 'list' || sub === 'ls') {
|
|
747
|
+
const users = app.users || [];
|
|
748
|
+
if (!users.length) { console.log('(sin usuarios)'); return 0; }
|
|
749
|
+
for (const u of users) {
|
|
750
|
+
console.log((u.disabled ? 'x' : 'o') + ' ' + u.name.padEnd(16) + '[' + u.role + ']');
|
|
751
|
+
}
|
|
752
|
+
return 0;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (sub === 'add') {
|
|
756
|
+
if (!config.userValidName(name)) { console.error('Nombre invalido (letras, numeros, . _ -; 1-32).'); return 1; }
|
|
757
|
+
if (config.findUser(app, name)) { console.error('Ya existe el usuario "' + name + '".'); return 1; }
|
|
758
|
+
let password = flags.password || '';
|
|
759
|
+
if (!password && process.stdin.isTTY) password = await askHidden('Contrasena para "' + name + '" (enter = generar): ');
|
|
760
|
+
let generated = false;
|
|
761
|
+
if (!password) { password = config.randomToken(12); generated = true; }
|
|
762
|
+
const role = config.USER_ROLES.includes(flags.role) ? flags.role : 'user';
|
|
763
|
+
app.users.push(config.makeUser(name, password, role));
|
|
764
|
+
persist('Usuario "' + name + '" agregado [' + role + '].');
|
|
765
|
+
console.log(' contrasena: ' + password + (generated ? ' (generada)' : ''));
|
|
766
|
+
return 0;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (sub === 'remove' || sub === 'del' || sub === 'rm') {
|
|
770
|
+
const user = config.findUser(app, name);
|
|
771
|
+
if (!user) { console.error('Usuario no encontrado: ' + name); return 1; }
|
|
772
|
+
if (user.role === 'admin' && config.adminCount(app) <= 1) { console.error('No podes borrar al ultimo admin.'); return 1; }
|
|
773
|
+
app.users = app.users.filter((u) => u.id !== user.id);
|
|
774
|
+
persist('Usuario "' + user.name + '" borrado.');
|
|
775
|
+
return 0;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (sub === 'passwd' || sub === 'password') {
|
|
779
|
+
if (!name) { console.error('Uso: openbridge users passwd <nombre> [--password <clave>]'); return 1; }
|
|
780
|
+
const extra = ['--user', name];
|
|
781
|
+
if (flags.password) extra.push('--password', String(flags.password));
|
|
782
|
+
return cmdPasswd(extra);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (sub === 'role') {
|
|
786
|
+
const user = config.findUser(app, name);
|
|
787
|
+
if (!user) { console.error('Usuario no encontrado: ' + name); return 1; }
|
|
788
|
+
const role = String(_[2] || flags.role || '').toLowerCase();
|
|
789
|
+
if (!config.USER_ROLES.includes(role)) { console.error('Rol invalido (admin|user).'); return 1; }
|
|
790
|
+
if (user.role === 'admin' && role !== 'admin' && config.adminCount(app) <= 1) { console.error('No podes quitarle el admin al ultimo admin.'); return 1; }
|
|
791
|
+
user.role = role;
|
|
792
|
+
persist('Rol de "' + user.name + '" -> ' + role + '.');
|
|
793
|
+
return 0;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (sub === 'disable' || sub === 'enable') {
|
|
797
|
+
const user = config.findUser(app, name);
|
|
798
|
+
if (!user) { console.error('Usuario no encontrado: ' + name); return 1; }
|
|
799
|
+
const disabled = sub === 'disable';
|
|
800
|
+
if (disabled && user.role === 'admin' && config.adminCount(app) <= 1) { console.error('No podes deshabilitar al ultimo admin.'); return 1; }
|
|
801
|
+
user.disabled = disabled;
|
|
802
|
+
persist('Usuario "' + user.name + '" ' + (disabled ? 'deshabilitado' : 'habilitado') + '.');
|
|
803
|
+
return 0;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
console.error('Uso: openbridge users [list|add|remove|passwd|role|disable|enable] [nombre] [--role admin|user] [--password <clave>]');
|
|
807
|
+
return 1;
|
|
808
|
+
}
|
|
809
|
+
|
|
713
810
|
// ---------------------------------------------------------------------------
|
|
714
811
|
// autostart
|
|
715
812
|
// ---------------------------------------------------------------------------
|
|
@@ -826,7 +923,8 @@ function usage() {
|
|
|
826
923
|
console.log('');
|
|
827
924
|
console.log(' init Configura la casa (workspace, contrasena, tunel)');
|
|
828
925
|
console.log(' server Arranca en segundo plano (muestra el estado al levantar)');
|
|
829
|
-
console.log(' passwd Cambia la contrasena de acceso (--password <clave>)');
|
|
926
|
+
console.log(' passwd Cambia la contrasena de acceso (--user <nombre> --password <clave>)');
|
|
927
|
+
console.log(' users Usuarios y roles (list|add|remove|passwd|role|disable|enable)');
|
|
830
928
|
console.log(' stop Detiene el server en segundo plano');
|
|
831
929
|
console.log(' status Estado del server, puente y chats');
|
|
832
930
|
console.log(' qr Muestra la URL (publica o local) como QR para el celular');
|
|
@@ -858,6 +956,7 @@ async function main(argv) {
|
|
|
858
956
|
switch (cmd) {
|
|
859
957
|
case 'init': return cmdInit(args.slice(1));
|
|
860
958
|
case 'passwd': case 'password': return cmdPasswd(args.slice(1));
|
|
959
|
+
case 'users': case 'user': return cmdUsers(args.slice(1));
|
|
861
960
|
case 'server': case 'start': return cmdServer(args.slice(1));
|
|
862
961
|
case 'stop': return cmdStop();
|
|
863
962
|
case 'status': return cmdStatus();
|
package/src/config.js
CHANGED
|
@@ -27,14 +27,17 @@ const DEFAULT_APP = {
|
|
|
27
27
|
port: 8799,
|
|
28
28
|
host: '127.0.0.1',
|
|
29
29
|
baseUrl: '',
|
|
30
|
-
username: 'admin',
|
|
31
|
-
password: null, //
|
|
30
|
+
username: 'admin', // legado (migra a users[0])
|
|
31
|
+
password: null, // legado (migra a users[0])
|
|
32
|
+
users: [], // [{ id, name, role, password, pv, created, disabled }]
|
|
32
33
|
csrfSecret: '',
|
|
33
34
|
bridgeToken: '',
|
|
34
35
|
vapid: { publicKey: '', privateKey: '' },
|
|
35
36
|
tunnel: { provider: 'tunnelmole', domain: '' },
|
|
36
37
|
};
|
|
37
38
|
|
|
39
|
+
const USER_ROLES = ['admin', 'user'];
|
|
40
|
+
|
|
38
41
|
function readJsonFile(file, fallback) {
|
|
39
42
|
try {
|
|
40
43
|
const raw = fs.readFileSync(file, 'utf8');
|
|
@@ -62,10 +65,17 @@ function readApp() {
|
|
|
62
65
|
const data = { ...DEFAULT_APP, ...readJsonFile(paths.appConfigPath(), DEFAULT_APP) };
|
|
63
66
|
data.vapid = { ...DEFAULT_APP.vapid, ...(data.vapid || {}) };
|
|
64
67
|
data.tunnel = { ...DEFAULT_APP.tunnel, ...(data.tunnel || {}) };
|
|
68
|
+
normalizeApp(data);
|
|
65
69
|
return data;
|
|
66
70
|
}
|
|
67
71
|
function writeApp(cfg) {
|
|
68
|
-
|
|
72
|
+
const out = { ...cfg };
|
|
73
|
+
// Una vez migrado a `users`, no dejamos la contrasena legado suelta.
|
|
74
|
+
if (Array.isArray(out.users) && out.users.length) {
|
|
75
|
+
delete out.username;
|
|
76
|
+
delete out.password;
|
|
77
|
+
}
|
|
78
|
+
writeJsonFile(paths.appConfigPath(), out);
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
function randomToken(bytes = 32) {
|
|
@@ -93,6 +103,65 @@ function verifyPassword(password, stored) {
|
|
|
93
103
|
return crypto.timingSafeEqual(calc, expected);
|
|
94
104
|
}
|
|
95
105
|
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Usuarios (multiusuario con roles admin|user). El modelo viejo de un solo
|
|
108
|
+
// `username`+`password` se migra solo a `users[0]`.
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
function userValidName(name) {
|
|
111
|
+
return typeof name === 'string' && /^[A-Za-z0-9._-]{1,32}$/.test(name);
|
|
112
|
+
}
|
|
113
|
+
function makeUser(name, password, role = 'user') {
|
|
114
|
+
return {
|
|
115
|
+
id: 'u_' + crypto.randomBytes(6).toString('hex'),
|
|
116
|
+
name: String(name),
|
|
117
|
+
role: USER_ROLES.includes(role) ? role : 'user',
|
|
118
|
+
password: hashPassword(password),
|
|
119
|
+
pv: 1,
|
|
120
|
+
created: new Date().toISOString(),
|
|
121
|
+
disabled: false,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// Devuelve la lista de usuarios, sintetizando el admin legado si hace falta.
|
|
125
|
+
function userList(app) {
|
|
126
|
+
if (app && Array.isArray(app.users) && app.users.length) return app.users;
|
|
127
|
+
if (app && app.password && app.password.hash) {
|
|
128
|
+
return [{
|
|
129
|
+
id: 'u1',
|
|
130
|
+
name: (typeof app.username === 'string' && app.username) ? app.username : 'admin',
|
|
131
|
+
role: 'admin',
|
|
132
|
+
password: app.password,
|
|
133
|
+
pv: 1,
|
|
134
|
+
created: '',
|
|
135
|
+
disabled: false,
|
|
136
|
+
}];
|
|
137
|
+
}
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
function normalizeApp(app) {
|
|
141
|
+
if (!app || typeof app !== 'object') return app;
|
|
142
|
+
if (!Array.isArray(app.users)) app.users = [];
|
|
143
|
+
app.users = app.users.filter((u) => u && typeof u === 'object' && typeof u.name === 'string' && u.password);
|
|
144
|
+
if (!app.users.length) {
|
|
145
|
+
const migrated = userList(app);
|
|
146
|
+
if (migrated.length) app.users = migrated;
|
|
147
|
+
}
|
|
148
|
+
return app;
|
|
149
|
+
}
|
|
150
|
+
function findUser(app, name) {
|
|
151
|
+
const n = String(name || '').trim().toLowerCase();
|
|
152
|
+
if (n === '') return null;
|
|
153
|
+
return userList(app).find((u) => String(u.name).toLowerCase() === n) || null;
|
|
154
|
+
}
|
|
155
|
+
function userById(app, id) {
|
|
156
|
+
return userList(app).find((u) => u.id === id) || null;
|
|
157
|
+
}
|
|
158
|
+
function verifyUserPassword(user, password) {
|
|
159
|
+
return !!user && verifyPassword(password, user.password);
|
|
160
|
+
}
|
|
161
|
+
function adminCount(app) {
|
|
162
|
+
return userList(app).filter((u) => u.role === 'admin' && !u.disabled).length;
|
|
163
|
+
}
|
|
164
|
+
|
|
96
165
|
// Claves VAPID (P-256) en el formato que espera web-push (base64url).
|
|
97
166
|
function genVapid() {
|
|
98
167
|
const webpush = require('web-push');
|
|
@@ -101,8 +170,9 @@ function genVapid() {
|
|
|
101
170
|
}
|
|
102
171
|
|
|
103
172
|
module.exports = {
|
|
104
|
-
DEFAULT_BRIDGE, DEFAULT_APP,
|
|
173
|
+
DEFAULT_BRIDGE, DEFAULT_APP, USER_ROLES,
|
|
105
174
|
readBridge, writeBridge, readApp, writeApp,
|
|
106
175
|
randomToken, hashPassword, verifyPassword, genVapid,
|
|
176
|
+
userValidName, makeUser, userList, normalizeApp, findUser, userById, verifyUserPassword, adminCount,
|
|
107
177
|
readJsonFile, writeJsonFile,
|
|
108
178
|
};
|
package/src/web/assets/app.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
var CFG = window.APP_CONFIG || {};
|
|
4
4
|
var CSRF = CFG.csrf || '';
|
|
5
5
|
var INITIAL_SESSION = typeof CFG.initialSession === 'number' ? CFG.initialSession : null;
|
|
6
|
+
var IS_ADMIN = String(CFG.userRole || 'user') === 'admin';
|
|
7
|
+
function isAdmin() { return IS_ADMIN; }
|
|
6
8
|
|
|
7
9
|
var els = {
|
|
8
10
|
body: document.body,
|
|
@@ -87,6 +89,12 @@ var els = {
|
|
|
87
89
|
btnCancel: document.getElementById('btnCancel'),
|
|
88
90
|
};
|
|
89
91
|
|
|
92
|
+
// Marca visual del usuario actual (admin en color de acento).
|
|
93
|
+
(function () {
|
|
94
|
+
var ub = document.getElementById('userBadge');
|
|
95
|
+
if (ub) ub.classList.toggle('admin', IS_ADMIN);
|
|
96
|
+
})();
|
|
97
|
+
|
|
90
98
|
var ONLINE_MAX_AGE_MS = 60000;
|
|
91
99
|
var PROC_TIMEOUT_MS = 45000;
|
|
92
100
|
var POLL_BASE_MS = 4000; // refresco pasivo (sin nada pendiente)
|
|
@@ -1264,7 +1272,7 @@ function renderHome(sessions) {
|
|
|
1264
1272
|
+ '<div class="cmeta">' + busy + esc(s.model || 'sin modelo') + ' · ' + esc(s.agent || 'build') + '</div>'
|
|
1265
1273
|
+ (s.preview ? '<div class="cpreview">' + prev + '</div>' : '')
|
|
1266
1274
|
+ '</div>'
|
|
1267
|
-
+ '<button type="button" class="kebab" aria-label="Opciones" title="Opciones">⋮</button>'
|
|
1275
|
+
+ (isAdmin() ? '<button type="button" class="kebab" aria-label="Opciones" title="Opciones">⋮</button>' : '')
|
|
1268
1276
|
+ '</div>';
|
|
1269
1277
|
}
|
|
1270
1278
|
html += '</div>';
|
|
@@ -1643,13 +1651,13 @@ function onMsgScroll() {
|
|
|
1643
1651
|
// reemplaza solo ese nodo; lo demás no se toca.
|
|
1644
1652
|
function msgSig(m) {
|
|
1645
1653
|
return (m.status || '') + '|' + (m.text || '').length + '|' + (m.reasoning || '').length
|
|
1646
|
-
+ '|' + (m.canceled ? 1 : 0) + '|' + (m.agent || '') + '|' + (m.img ? m.img.length : 0);
|
|
1654
|
+
+ '|' + (m.canceled ? 1 : 0) + '|' + (m.agent || '') + '|' + (m.author || '') + '|' + (m.img ? m.img.length : 0);
|
|
1647
1655
|
}
|
|
1648
1656
|
|
|
1649
1657
|
function msgNodeHtml(m, chatQuery) {
|
|
1650
1658
|
var cls = m.role === 'user' ? 'mine' : 'theirs';
|
|
1651
1659
|
if (m.status === 'canceled') cls += ' canceled';
|
|
1652
|
-
var who = m.role === 'user' ? '❯ vos' : '● Agente';
|
|
1660
|
+
var who = m.role === 'user' ? '❯ ' + esc(m.author || 'vos') : '● Agente';
|
|
1653
1661
|
if (m.agent === 'plan') cls += ' plan';
|
|
1654
1662
|
var agentBadge = m.agent ? ' <span class="abadge ' + esc(m.agent) + '">' + esc(m.agent) + '</span>' : '';
|
|
1655
1663
|
var stopBadge = m.canceled ? ' <span class="stopbadge">⏹ detenido</span>' : '';
|
|
@@ -3046,7 +3054,7 @@ async function loadChanges(folder) {
|
|
|
3046
3054
|
}
|
|
3047
3055
|
var list = '<div class="chg-files">';
|
|
3048
3056
|
for (var i = 0; i < files.length; i++) {
|
|
3049
|
-
var rev = files[i].status !== '??'
|
|
3057
|
+
var rev = (isAdmin() && files[i].status !== '??')
|
|
3050
3058
|
? '<button type="button" class="chg-rev" data-path="' + esc(files[i].path) + '" title="Revertir este archivo" aria-label="Revertir este archivo">↩</button>'
|
|
3051
3059
|
: '';
|
|
3052
3060
|
list += '<div class="chg-row"><span class="chg-st ' + chgStatusClass(files[i].status) + '">' + esc(files[i].status) + '</span>'
|
|
@@ -3054,7 +3062,7 @@ async function loadChanges(folder) {
|
|
|
3054
3062
|
}
|
|
3055
3063
|
list += '</div>';
|
|
3056
3064
|
var tracked = files.some(function (f) { return f.status !== '??'; });
|
|
3057
|
-
var toolbar = tracked
|
|
3065
|
+
var toolbar = (isAdmin() && tracked)
|
|
3058
3066
|
? '<div class="files-toolbar"><button type="button" class="linkbtn" id="chgRevert">↩ revertir cambios rastreados</button></div>'
|
|
3059
3067
|
: '';
|
|
3060
3068
|
els.viewChanges.innerHTML = changesHead(folder, status.branch) + toolbar + list + '<div class="placeholder">cargando diff…</div>';
|
|
@@ -3075,6 +3083,7 @@ async function loadChanges(folder) {
|
|
|
3075
3083
|
// Sirve para un archivo puntual (boton de la fila) o para todos.
|
|
3076
3084
|
if (els.viewChanges) {
|
|
3077
3085
|
els.viewChanges.addEventListener('click', function (e) {
|
|
3086
|
+
if (!isAdmin()) return;
|
|
3078
3087
|
var folder = changesFolder();
|
|
3079
3088
|
if (!folder) return;
|
|
3080
3089
|
var one = e.target.closest('.chg-rev');
|
|
@@ -3397,21 +3406,26 @@ function renderPreviewView() {
|
|
|
3397
3406
|
}
|
|
3398
3407
|
els.viewPreview.innerHTML = '<div class="view-head"><h2>vista previa</h2>'
|
|
3399
3408
|
+ '<div class="view-sub">túneles del proyecto (TunnelMole, corren en tu PC)</div></div>'
|
|
3400
|
-
+
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3409
|
+
+ (isAdmin()
|
|
3410
|
+
? '<div class="tunnel-form">'
|
|
3411
|
+
+ '<input type="text" id="tunnelPort" inputmode="numeric" placeholder="puerto (ej: 8080)" value="' + esc(state.previewPort) + '" autocomplete="off">'
|
|
3412
|
+
+ '<button type="button" class="linkbtn" id="tunnelStart">abrir túnel</button>'
|
|
3413
|
+
+ '</div>'
|
|
3414
|
+
: '')
|
|
3404
3415
|
+ '<div class="tnote-sec" style="margin:0 0 12px">los túneles son públicos en internet y viven mientras el puente esté corriendo · cerrálos al terminar</div>'
|
|
3405
3416
|
+ '<div id="tunnelList"></div>';
|
|
3406
3417
|
var portInput = document.getElementById('tunnelPort');
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3418
|
+
if (portInput) {
|
|
3419
|
+
portInput.addEventListener('input', function () {
|
|
3420
|
+
state.previewPort = portInput.value.replace(/[^0-9]/g, '');
|
|
3421
|
+
portInput.value = state.previewPort;
|
|
3422
|
+
});
|
|
3423
|
+
portInput.addEventListener('keydown', function (e) {
|
|
3424
|
+
if (e.key === 'Enter') { e.preventDefault(); startTunnel(); }
|
|
3425
|
+
});
|
|
3426
|
+
var tunStartBtn = document.getElementById('tunnelStart');
|
|
3427
|
+
if (tunStartBtn) tunStartBtn.addEventListener('click', startTunnel);
|
|
3428
|
+
}
|
|
3415
3429
|
renderTunnels();
|
|
3416
3430
|
refreshTunnels();
|
|
3417
3431
|
}
|
|
@@ -3428,7 +3442,8 @@ function renderTunnels(busyMsg) {
|
|
|
3428
3442
|
+ '<div class="thead"><span class="dot"' + (ready ? '' : ' style="background:var(--muted)"') + '></span>'
|
|
3429
3443
|
+ '<b>puerto ' + esc(t.port) + '</b>'
|
|
3430
3444
|
+ '<span class="tnote">' + (ready ? 'activo' : 'levantando…') + '</span>'
|
|
3431
|
-
+ '<button type="button" class="linkbtn" data-tstop="' + esc(t.port) + '">cerrar túnel</button
|
|
3445
|
+
+ (isAdmin() ? '<button type="button" class="linkbtn" data-tstop="' + esc(t.port) + '">cerrar túnel</button>' : '')
|
|
3446
|
+
+ '</div>'
|
|
3432
3447
|
+ (t.https ? '<div class="turl"><a href="' + esc(t.https) + '" target="_blank" rel="noopener noreferrer">' + esc(t.https) + '</a>'
|
|
3433
3448
|
+ '<button type="button" class="copybtn" data-tcopy="' + esc(t.https) + '" title="Copiar URL">⧉</button></div>' : '')
|
|
3434
3449
|
+ (t.http && t.http !== t.https ? '<div class="turl"><a href="' + esc(t.http) + '" target="_blank" rel="noopener noreferrer">' + esc(t.http) + '</a>'
|
|
@@ -3588,6 +3603,7 @@ function enterProcsView() {
|
|
|
3588
3603
|
}
|
|
3589
3604
|
|
|
3590
3605
|
function renderProcView(folder) {
|
|
3606
|
+
var admin = isAdmin();
|
|
3591
3607
|
els.viewProcs.innerHTML =
|
|
3592
3608
|
'<div class="pp-head"><b>procesos</b><span class="pp-sub">' + esc(sessionProcLabel(folder)) + '</span>'
|
|
3593
3609
|
+ (state.currentId !== null ? '<button type="button" class="linkbtn pp-back" id="ppBack">← volver al chat</button>' : '')
|
|
@@ -3595,35 +3611,42 @@ function renderProcView(folder) {
|
|
|
3595
3611
|
+ '<div class="pp-status" id="procStatus"></div>'
|
|
3596
3612
|
+ '<div id="ppPortWarn"></div>'
|
|
3597
3613
|
+ '<pre id="procLog"><span class="pl-empty">sin salida todavía</span></pre>'
|
|
3598
|
-
+ '<div class="pp-tunnel">'
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
+ '<div class="pp-chips" id="procChips"></div>'
|
|
3605
|
-
+ '<div class="pp-form"><input type="text" id="procCmdInput" placeholder="npm run dev · node server.js …" autocomplete="off">'
|
|
3606
|
-
|
|
3614
|
+
+ (admin ? '<div class="pp-tunnel">'
|
|
3615
|
+
+ '<div class="pp-tunhead">compartir por túnel (tunnelmole, corre en tu PC)</div>'
|
|
3616
|
+
+ '<div class="pp-tunform"><input type="text" id="ppTunPort" inputmode="numeric" placeholder="puerto (ej: 3000)" autocomplete="off">'
|
|
3617
|
+
+ '<button type="button" class="linkbtn" id="ppTunStart">abrir túnel</button></div>'
|
|
3618
|
+
+ '<div id="ppTunList"></div>'
|
|
3619
|
+
+ '</div>' : '')
|
|
3620
|
+
+ (admin ? '<div class="pp-chips" id="procChips"></div>' : '')
|
|
3621
|
+
+ (admin ? '<div class="pp-form"><input type="text" id="procCmdInput" placeholder="npm run dev · node server.js …" autocomplete="off">'
|
|
3622
|
+
+ '<button type="button" class="linkbtn" id="procStartBtn">iniciar</button></div>' : '')
|
|
3623
|
+
+ (!admin ? '<div class="placeholder">solo un admin puede iniciar procesos o túneles.</div>' : '');
|
|
3607
3624
|
var back = document.getElementById('ppBack');
|
|
3608
3625
|
if (back) back.addEventListener('click', function () { showView('chat'); });
|
|
3609
3626
|
var inp = document.getElementById('procCmdInput');
|
|
3610
|
-
inp
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3627
|
+
if (inp) {
|
|
3628
|
+
inp.value = procState.input || '';
|
|
3629
|
+
inp.addEventListener('input', function () { procState.input = inp.value; });
|
|
3630
|
+
inp.addEventListener('keydown', function (e) {
|
|
3631
|
+
if (e.key === 'Enter') { e.preventDefault(); startSessionProc(); }
|
|
3632
|
+
});
|
|
3633
|
+
}
|
|
3634
|
+
var startBtn = document.getElementById('procStartBtn');
|
|
3635
|
+
if (startBtn) startBtn.addEventListener('click', startSessionProc);
|
|
3616
3636
|
var tunPort = document.getElementById('ppTunPort');
|
|
3617
|
-
tunPort
|
|
3618
|
-
|
|
3619
|
-
tunPort.
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3637
|
+
if (tunPort) {
|
|
3638
|
+
tunPort.value = procState.detectedPort || '';
|
|
3639
|
+
tunPort.addEventListener('input', function () {
|
|
3640
|
+
tunPort.value = tunPort.value.replace(/[^0-9]/g, '');
|
|
3641
|
+
if (tunPort.value) state.previewPort = tunPort.value;
|
|
3642
|
+
});
|
|
3643
|
+
tunPort.addEventListener('keydown', function (e) {
|
|
3644
|
+
if (e.key === 'Enter') { e.preventDefault(); panelStartTunnel(); }
|
|
3645
|
+
});
|
|
3646
|
+
}
|
|
3647
|
+
var tunStart = document.getElementById('ppTunStart');
|
|
3648
|
+
if (tunStart) tunStart.addEventListener('click', panelStartTunnel);
|
|
3649
|
+
if (admin) buildProcChips();
|
|
3627
3650
|
procLogBox = document.getElementById('procLog');
|
|
3628
3651
|
renderProcStatus();
|
|
3629
3652
|
renderPortWarn();
|
|
@@ -3908,7 +3931,8 @@ function renderProcTunnels(busyMsg) {
|
|
|
3908
3931
|
var ready = !!(t.https || t.http);
|
|
3909
3932
|
html += '<div class="tunnel"><div class="thead"><span class="dot"' + (ready ? '' : ' style="background:var(--muted)"') + '></span>'
|
|
3910
3933
|
+ '<b>puerto ' + esc(t.port) + '</b><span class="tnote">' + (ready ? 'activo' : 'levantando…') + '</span>'
|
|
3911
|
-
+ '<button type="button" class="linkbtn" data-ptstop="' + esc(t.port) + '">cerrar</button
|
|
3934
|
+
+ (isAdmin() ? '<button type="button" class="linkbtn" data-ptstop="' + esc(t.port) + '">cerrar</button>' : '')
|
|
3935
|
+
+ '</div>'
|
|
3912
3936
|
+ (t.https ? '<div class="turl"><a href="' + esc(t.https) + '" target="_blank" rel="noopener noreferrer">' + esc(t.https) + '</a>'
|
|
3913
3937
|
+ '<button type="button" class="copybtn" data-ptcopy="' + esc(t.https) + '" title="Copiar URL">⧉</button></div>' : '')
|
|
3914
3938
|
+ (t.http && t.http !== t.https ? '<div class="turl"><a href="' + esc(t.http) + '" target="_blank" rel="noopener noreferrer">' + esc(t.http) + '</a>'
|
package/src/web/assets/sw.js
CHANGED
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) => ({ '<': '<', '>': '>', '&': '&' }[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
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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('
|
|
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',
|
|
@@ -259,8 +286,9 @@ async function handleApi(ctx) {
|
|
|
259
286
|
return ok({ ok: true, session: await store.getSession(id) });
|
|
260
287
|
}
|
|
261
288
|
case 'session_delete': {
|
|
262
|
-
|
|
263
|
-
if (!
|
|
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);
|
|
264
292
|
const id = parseInt(body.id, 10) || 0;
|
|
265
293
|
if (id <= 0) return ok({ ok: false, error: 'id invalido' }, 400);
|
|
266
294
|
await store.deleteSession(id);
|
|
@@ -339,8 +367,8 @@ async function handleApi(ctx) {
|
|
|
339
367
|
return ok({ ok: true, cancel: false });
|
|
340
368
|
}
|
|
341
369
|
case 'send': {
|
|
342
|
-
|
|
343
|
-
if (!
|
|
370
|
+
const s = auth.requireCsrf(app, req, res);
|
|
371
|
+
if (!s) return;
|
|
344
372
|
const sid = parseInt(body.session, 10) || 0;
|
|
345
373
|
const sess = sid > 0 ? await store.getSession(sid) : null;
|
|
346
374
|
if (!sess) return ok({ ok: false, error: 'Sesion no encontrada' }, 404);
|
|
@@ -352,7 +380,7 @@ async function handleApi(ctx) {
|
|
|
352
380
|
}
|
|
353
381
|
if (text === '' && img === '') return ok({ ok: false, error: 'Mensaje vacio' }, 400);
|
|
354
382
|
if (Array.from(text).length > 10000) return ok({ ok: false, error: 'Mensaje demasiado largo' }, 400);
|
|
355
|
-
const extra = { agent: String(sess.agent || 'build') };
|
|
383
|
+
const extra = { agent: String(sess.agent || 'build'), author: s.name };
|
|
356
384
|
if (img !== '') extra.img = img;
|
|
357
385
|
const id = await store.addMessage(sid, 'user', text, 'pending', extra);
|
|
358
386
|
if (store.sessionHasDefaultName(sess)) {
|
|
@@ -652,11 +680,13 @@ async function handleApi(ctx) {
|
|
|
652
680
|
let aid = null;
|
|
653
681
|
const found = await store.messagesUpdate(sid, (data) => {
|
|
654
682
|
let msgFound = false;
|
|
683
|
+
let author = '';
|
|
655
684
|
for (const msg of data.messages) {
|
|
656
685
|
if (parseInt(msg.id, 10) === userId) {
|
|
657
686
|
msg.status = 'done';
|
|
658
687
|
msg.answered_ts = store.nowIso();
|
|
659
688
|
delete msg.cancel_requested;
|
|
689
|
+
if (typeof msg.author === 'string') author = msg.author;
|
|
660
690
|
msgFound = true;
|
|
661
691
|
break;
|
|
662
692
|
}
|
|
@@ -677,6 +707,7 @@ async function handleApi(ctx) {
|
|
|
677
707
|
if (reasoning !== '') m.reasoning = reasoning; else delete m.reasoning;
|
|
678
708
|
if (canceled) m.canceled = true; else delete m.canceled;
|
|
679
709
|
if (!m.agent) m.agent = store.messageAgentOf(data, userId, (sess && sess.agent) || '');
|
|
710
|
+
if (author && !m.author) m.author = author;
|
|
680
711
|
delete m.draft_for;
|
|
681
712
|
} else {
|
|
682
713
|
aid = data.nextId;
|
|
@@ -684,6 +715,7 @@ async function handleApi(ctx) {
|
|
|
684
715
|
const nm = { id: aid, role: 'assistant', text, ts: store.nowIso(), status: 'done', agent: store.messageAgentOf(data, userId, (sess && sess.agent) || '') };
|
|
685
716
|
if (reasoning !== '') nm.reasoning = reasoning;
|
|
686
717
|
if (canceled) nm.canceled = true;
|
|
718
|
+
if (author) nm.author = author;
|
|
687
719
|
data.messages.push(nm);
|
|
688
720
|
}
|
|
689
721
|
});
|
|
@@ -723,13 +755,14 @@ async function handleApi(ctx) {
|
|
|
723
755
|
return ok({ ok: true, path: rel, size, mtime: Math.floor(st.mtimeMs / 1000), kind: 'text', content });
|
|
724
756
|
}
|
|
725
757
|
case 'run_oc': {
|
|
726
|
-
|
|
727
|
-
if (!
|
|
758
|
+
const s = auth.requireCsrf(app, req, res);
|
|
759
|
+
if (!s) return;
|
|
728
760
|
const bridge = bodyBridge(req, query, body) || await webBridge(req, query);
|
|
729
761
|
const file = paths.bridgeCatalogFile(bridge);
|
|
730
762
|
const cmd = String(body.cmd || '').toLowerCase().replace(/[^a-z_]/g, '');
|
|
731
763
|
const args = Array.isArray(body.args) ? body.args : [];
|
|
732
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);
|
|
733
766
|
const spec = OC_ALLOWED[cmd];
|
|
734
767
|
const cleanArgs = [];
|
|
735
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;
|
|
@@ -1204,7 +1209,10 @@
|
|
|
1204
1209
|
<span class="dot" id="bridgeDot"></span>
|
|
1205
1210
|
<span id="bridgeStatusText">puente: desconocido</span>
|
|
1206
1211
|
</div>
|
|
1207
|
-
<
|
|
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>
|
|
1208
1216
|
</div>
|
|
1209
1217
|
<div class="resizer" id="sbResizer" title="Arrastrá para ajustar · doble clic para restablecer"></div>
|
|
1210
1218
|
</aside>
|
|
@@ -1344,7 +1352,9 @@
|
|
|
1344
1352
|
themes: {{THEMES_JSON}},
|
|
1345
1353
|
sseDisabled: {{SSE_DISABLED}},
|
|
1346
1354
|
pushEnabled: {{PUSH_ENABLED}},
|
|
1347
|
-
pushKey: "{{PUSH_KEY}}"
|
|
1355
|
+
pushKey: "{{PUSH_KEY}}",
|
|
1356
|
+
userName: "{{USER_NAME}}",
|
|
1357
|
+
userRole: "{{USER_ROLE}}"
|
|
1348
1358
|
};
|
|
1349
1359
|
</script>
|
|
1350
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="
|
|
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">
|