From 0f6c5ece4e97ab0df90185c4c176c4195d7c2f43 Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Fri, 21 Aug 2026 19:58:56 -0600 Subject: [PATCH] Se puede entrar con cuenta, sin que haga falta para nada Co-Authored-By: Claude Opus 5 --- package.json | 3 +- server/.env.example | 19 + server/package.json | 3 +- server/src/cuentas.ts | 251 ++++ server/src/demos/cuentas.ts | 202 +++ server/src/engine/proxy.ts | 1 + server/src/index.ts | 201 ++- server/src/rooms.ts | 52 +- server/src/storage/sqlite.ts | 183 ++- server/src/storage/types.ts | 69 + web/src/App.tsx | 2662 ++++++++++++++++++---------------- web/src/CuentaPanel.tsx | 86 ++ web/src/cuenta.ts | 105 ++ web/src/historial-salas.ts | 25 +- web/src/i18n.tsx | 16 + web/src/socket.ts | 11 +- web/src/styles.css | 24 + 17 files changed, 2623 insertions(+), 1290 deletions(-) create mode 100644 server/src/cuentas.ts create mode 100644 server/src/demos/cuentas.ts create mode 100644 web/src/CuentaPanel.tsx create mode 100644 web/src/cuenta.ts diff --git a/package.json b/package.json index b771f59..5438473 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "demo:exportar": "npm run demo:exportar -w server", "uso": "npm run uso -w server --", "demo:variables": "npm run demo:variables -w server", - "demo:publicar": "npm run demo:publicar -w server" + "demo:publicar": "npm run demo:publicar -w server", + "demo:cuentas": "npm run demo:cuentas -w server" }, "engines": { "node": ">=22" diff --git a/server/.env.example b/server/.env.example index fa29996..97da6f5 100644 --- a/server/.env.example +++ b/server/.env.example @@ -46,3 +46,22 @@ # configurada, que es algo que solo tú puedes resolver. # CLOUDFLARE_API_TOKEN= # CLOUDFLARE_ACCOUNT_ID= + +# ── Entrar con cuenta (opcional) ───────────────────────────────────────────── +# Con esto aparece el botón de entrar con Google. Tener cuenta es OPCIONAL para +# quien usa Multi: se entra a cualquier sala con el link, como siempre. La +# cuenta solo sirve para que tus salas te sigan entre dispositivos y para que tu +# nombre y tu foto sean los mismos en todas. +# +# Se sacan en Google Cloud Console: creas un proyecto, configuras la pantalla de +# consentimiento, y creas credenciales de "ID de cliente de OAuth" tipo +# aplicación web. Ahí registras las URLs de retorno, que tienen que ser +# exactamente estas: +# +# https://TU-DOMINIO/auth/google/callback +# http://localhost:4000/auth/google/callback (para desarrollo) +# +# Sin esto Multi corre igual y el botón de entrar no aparece. Lo dice en el +# arranque, para que no se descubra por accidente. +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= diff --git a/server/package.json b/server/package.json index bc641c4..8659172 100644 --- a/server/package.json +++ b/server/package.json @@ -28,7 +28,8 @@ "demo:exportar": "tsx src/demos/exportar.ts", "uso": "tsx --no-warnings src/demos/uso.ts", "demo:variables": "tsx src/demos/variables.ts", - "demo:publicar": "tsx src/demos/publicar.ts" + "demo:publicar": "tsx src/demos/publicar.ts", + "demo:cuentas": "tsx src/demos/cuentas.ts" }, "dependencies": { "@fastify/cors": "^11.3.0", diff --git a/server/src/cuentas.ts b/server/src/cuentas.ts new file mode 100644 index 0000000..a102d79 --- /dev/null +++ b/server/src/cuentas.ts @@ -0,0 +1,251 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import type { StoredUsuario } from "./storage/types.js"; + +/** + * Las cuentas de Multi: entrar con Google, y nada más. + * + * TENER CUENTA ES OPCIONAL Y LO SEGUIRÁ SIENDO. Se entra a cualquier sala con + * el link y ya, como siempre. Esto existe solo para quien quiere que sus salas + * le sigan del celular a la compu, y que su cara y su nombre sean los mismos en + * todas. Si algún camino de este archivo hace que alguien no pueda entrar, + * está mal escrito. + * + * Se delega en Google en vez de guardar contraseñas: así Multi nunca almacena + * el secreto de nadie, no hay que mandar correos de recuperación (que serían + * otro servicio que dar de alta), y la foto de perfil llega gratis. + * + * Ojo con la asimetría contra `keys.ts`, porque es deliberada y opuesta: la API + * key NUNCA va a disco, porque es un secreto de otro y guardarlo es una + * responsabilidad que un proyecto que cualquiera hospeda no debe tener. La + * sesión SÍ va a disco, porque su razón de existir es sobrevivir al reinicio. + * Son dos decisiones contrarias por dos razones distintas; no se unifican. + */ + +/** Cuánto dura una sesión. Larga a propósito: volver a entrar es fricción. */ +const DURACION_MS = 90 * 24 * 60 * 60 * 1000; + +export const COOKIE_SESION = "multi_sesion"; + +/** Lo que el front necesita saber de quien entró. */ +export interface Usuario { + id: string; + nombre: string; + correo: string; + foto: string | null; +} + +export interface CredencialGoogle { + clientId: string; + clientSecret: string; +} + +/** + * Las credenciales de Google del `.env`, o null si quien corre Multi no las puso. + * + * Se lee cada vez y no al arrancar para que agregarlas no obligue a reiniciar, + * igual que la credencial de publicar. Sin ellas no hay botón de entrar y Multi + * funciona igual que siempre: es una capacidad de menos, no un error. + */ +export function credencialDeGoogle(): CredencialGoogle | null { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + if (!clientId || !clientSecret) return null; + return { clientId, clientSecret }; +} + +// ── El ida y vuelta con Google ────────────────────────────────────────────── + +/** + * Los `state` que están en vuelo. + * + * Son de un solo uso y sirven para que el callback pueda comprobar que la + * vuelta corresponde a una ida que salió de aquí. Viven en memoria porque duran + * lo que tarda alguien en apretar el botón de Google: si el server reinicia a + * media autenticación, se vuelve a intentar y ya. + */ +const enVuelo = new Map(); +const VIDA_STATE_MS = 10 * 60 * 1000; + +export function nuevoState(): string { + limpiarStates(); + const state = randomBytes(16).toString("base64url"); + enVuelo.set(state, Date.now() + VIDA_STATE_MS); + return state; +} + +/** ¿Esta vuelta corresponde a una ida nuestra? Consume el state. */ +export function consumirState(state: string | undefined): boolean { + if (!state) return false; + const expira = enVuelo.get(state); + enVuelo.delete(state); + return expira != null && expira > Date.now(); +} + +function limpiarStates(): void { + const ahora = Date.now(); + for (const [state, expira] of enVuelo) { + if (expira <= ahora) enVuelo.delete(state); + } +} + +/** + * A dónde mandar a la persona para que Google le pregunte si nos deja entrar. + * + * `openid profile email` es el mínimo que da nombre, correo y foto. No se pide + * nada más: cada permiso extra es una pantalla más intimidante y aquí solo hace + * falta saber quién es. + */ +export function urlDeAutorizacion(cred: CredencialGoogle, state: string, redirectUri: string): string { + const params = new URLSearchParams({ + client_id: cred.clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: "openid profile email", + state, + }); + return `https://accounts.google.com/o/oauth2/v2/auth?${params}`; +} + +/** + * Cambia el código que Google devolvió por el token que dice quién es la persona. + * + * Devuelve null si Google no lo acepta, sin distinguir por qué: lo que se puede + * hacer al respecto es lo mismo en todos los casos (volver a intentar), y el + * detalle se queda en el log del server. + */ +export async function intercambiarCodigo( + cred: CredencialGoogle, + code: string, + redirectUri: string, +): Promise { + try { + const res = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: cred.clientId, + client_secret: cred.clientSecret, + redirect_uri: redirectUri, + grant_type: "authorization_code", + }), + }); + if (!res.ok) { + console.error(`[cuentas] Google rechazó el código: ${res.status}`); + return null; + } + const datos = (await res.json()) as { id_token?: string }; + return datos.id_token ?? null; + } catch (err) { + console.error("[cuentas] no se pudo hablar con Google:", err); + return null; + } +} + +export interface DatosDeGoogle { + sub: string; + nombre: string; + correo: string; + foto: string | null; +} + +/** + * Quién es, según el token que dio Google. + * + * NO se verifica la firma, y es correcto: este token no llegó por el navegador + * de nadie, lo pedimos nosotros por HTTPS directamente a Google. El TLS ya + * garantiza el origen, así que validar el JWT contra las llaves públicas sería + * comprobar dos veces lo mismo. + * + * Ojo con `sub`: ese es el identificador de la cuenta, y es el que no cambia + * aunque la persona cambie de correo. La cuenta se ata a eso, nunca al correo. + */ +export function datosDelToken(idToken: string): DatosDeGoogle | null { + try { + const payload = idToken.split(".")[1]; + if (!payload) return null; + const json = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record< + string, + unknown + >; + const sub = typeof json.sub === "string" ? json.sub : ""; + if (!sub) return null; + return { + sub, + nombre: typeof json.name === "string" ? json.name : "sin nombre", + correo: typeof json.email === "string" ? json.email : "", + foto: typeof json.picture === "string" ? json.picture : null, + }; + } catch { + // Un token que no se puede leer es un token que no sirve. Quien llama + // decide qué hacer; aquí no hay nada que rescatar. + return null; + } +} + +// ── Sesiones ──────────────────────────────────────────────────────────────── + +export function nuevoTokenDeSesion(): string { + return randomBytes(32).toString("base64url"); +} + +export function nuevoIdDeUsuario(): string { + return randomUUID(); +} + +export function cuandoExpira(): number { + return Date.now() + DURACION_MS; +} + +/** + * La cabecera que deja la sesión en el navegador. + * + * `HttpOnly` no es opcional aquí: cuando Multi se corre con `npm start`, el + * preview de la sala se sirve desde el MISMO origen, y ese preview ejecuta + * código que escribió un agente. Un token que el JavaScript de la página pueda + * leer sería un token que ese código puede leer. Así no lo alcanza. + * + * `SameSite=Lax` deja pasar la vuelta de Google (es una navegación de primer + * nivel) y bloquea peticiones cruzadas de terceros. + */ +export function cookieDeSesion(token: string, seguro: boolean): string { + const partes = [ + `${COOKIE_SESION}=${token}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + `Max-Age=${Math.floor(DURACION_MS / 1000)}`, + ]; + if (seguro) partes.push("Secure"); + return partes.join("; "); +} + +export function cookieBorrada(seguro: boolean): string { + const partes = [`${COOKIE_SESION}=`, "Path=/", "HttpOnly", "SameSite=Lax", "Max-Age=0"]; + if (seguro) partes.push("Secure"); + return partes.join("; "); +} + +/** + * El token de sesión que venga en la cabecera de cookies. + * + * Se parsea a mano en vez de meter una dependencia: es una cabecera con pares + * separados por punto y coma. La usa tanto el HTTP como el socket, que lee la + * misma cookie del handshake. + */ +export function tokenDeCookie(cabecera: string | undefined): string | undefined { + if (!cabecera) return undefined; + for (const parte of cabecera.split(";")) { + const igual = parte.indexOf("="); + if (igual <= 0) continue; + if (parte.slice(0, igual).trim() !== COOKIE_SESION) continue; + const valor = parte.slice(igual + 1).trim(); + return valor || undefined; + } + return undefined; +} + +/** Lo que se le manda al front. Nunca el token ni el `google_sub`. */ +export function aUsuarioPublico(u: StoredUsuario): Usuario { + return { id: u.id, nombre: u.nombre, correo: u.correo, foto: u.foto ?? null }; +} diff --git a/server/src/demos/cuentas.ts b/server/src/demos/cuentas.ts new file mode 100644 index 0000000..783922c --- /dev/null +++ b/server/src/demos/cuentas.ts @@ -0,0 +1,202 @@ +import { + COOKIE_SESION, + consumirState, + cookieBorrada, + cookieDeSesion, + credencialDeGoogle, + datosDelToken, + nuevoState, + tokenDeCookie, + urlDeAutorizacion, +} from "../cuentas.js"; +import { addMember, colorDe, type Room } from "../rooms.js"; + +/** + * Demo: las cuentas, que son OPCIONALES. + * Uso: npm run demo:cuentas + * + * Lo que se prueba es la parte que decide: leer quién eres de lo que dice + * Google, que un state no se pueda reusar, y sobre todo que sin cuenta todo + * siga funcionando igual. El ida y vuelta con Google no se prueba aquí: haría + * falta un navegador y credenciales de verdad. + * + * No necesita red ni servidor. + */ + +let pass = 0; +let fail = 0; +function check(name: string, ok: boolean, detail = ""): void { + if (ok) { + pass++; + console.log(` [ok] ${name}`); + } else { + fail++; + console.log(` [X] ${name} ${detail}`); + } +} + +/** Un id_token como el que devuelve Google, sin firmar (aquí no se verifica). */ +function tokenDeMentira(payload: Record): string { + const cuerpo = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `cabecera.${cuerpo}.firma`; +} + +/** Una sala de mentira, solo con lo que `addMember` toca. */ +function salaVacia(): Room { + return { members: new Map() } as unknown as Room; +} + +function main(): void { + console.log("\n=== cuentas (opcionales) ===\n"); + + console.log("1. Sin credenciales configuradas no se ofrece entrar"); + { + const antes = { id: process.env.GOOGLE_CLIENT_ID, secret: process.env.GOOGLE_CLIENT_SECRET }; + delete process.env.GOOGLE_CLIENT_ID; + delete process.env.GOOGLE_CLIENT_SECRET; + check("sin nada, no hay login", credencialDeGoogle() === null); + + process.env.GOOGLE_CLIENT_ID = "solo-el-id"; + check("con una sola tampoco alcanza", credencialDeGoogle() === null); + + process.env.GOOGLE_CLIENT_SECRET = "y-el-secreto"; + check("con las dos ya sirve", credencialDeGoogle() !== null); + + if (antes.id) process.env.GOOGLE_CLIENT_ID = antes.id; + else delete process.env.GOOGLE_CLIENT_ID; + if (antes.secret) process.env.GOOGLE_CLIENT_SECRET = antes.secret; + else delete process.env.GOOGLE_CLIENT_SECRET; + } + + console.log("\n2. Quién eres, según lo que dijo Google"); + { + const datos = datosDelToken( + tokenDeMentira({ + sub: "1234567890", + name: "Dan", + email: "dan@ejemplo.com", + picture: "https://lh3.googleusercontent.com/foto", + }), + ); + check("saca el identificador", datos?.sub === "1234567890", String(datos?.sub)); + check("el nombre", datos?.nombre === "Dan"); + check("el correo", datos?.correo === "dan@ejemplo.com"); + check("y la foto", datos?.foto === "https://lh3.googleusercontent.com/foto"); + } + + console.log("\n3. Una cuenta de Google sin foto entra igual"); + { + // No todas las cuentas tienen foto, y eso no puede impedir entrar: se cae a + // la inicial de siempre. + const datos = datosDelToken(tokenDeMentira({ sub: "sin-foto", name: "Ana", email: "a@b.c" })); + check("entra sin foto", datos !== null); + check("y la foto queda vacía", datos?.foto === null); + } + + console.log("\n4. Un token que no se puede leer no revienta"); + { + check("basura", datosDelToken("no-es-un-token") === null); + check("vacío", datosDelToken("") === null); + check("sin la parte de en medio", datosDelToken("a..c") === null); + // Sin `sub` no hay a quién atar la cuenta, así que no sirve aunque se lea. + check("sin identificador", datosDelToken(tokenDeMentira({ name: "X" })) === null); + } + + console.log("\n5. El state de la ida es de un solo uso"); + { + const state = nuevoState(); + check("el primero vale", consumirState(state)); + // Si se pudiera reusar, alguien podría reenviar una vuelta de Google vieja. + check("el segundo ya no", !consumirState(state)); + check("uno inventado tampoco", !consumirState("me-lo-acabo-de-inventar")); + check("y sin state, no", !consumirState(undefined)); + } + + console.log("\n6. La URL a la que se manda a la persona"); + { + const url = urlDeAutorizacion( + { clientId: "mi-id", clientSecret: "mi-secreto" }, + "el-state", + "https://multti.app/auth/google/callback", + ); + check("va a Google", url.startsWith("https://accounts.google.com/o/oauth2/v2/auth?")); + check("pide lo mínimo para saber quién es", url.includes("scope=openid+profile+email")); + check("lleva el state", url.includes("state=el-state")); + // El secreto solo viaja en el intercambio, de server a server. Si saliera + // aquí quedaría en el historial del navegador de todo el mundo. + check("y NUNCA el secreto", !url.includes("mi-secreto")); + } + + console.log("\n7. La cookie de sesión no la puede leer el navegador"); + { + const cookie = cookieDeSesion("un-token", true); + // HttpOnly es lo que la pone fuera del alcance del código del preview, que + // corre en el mismo origen y lo escribió un agente. + check("es HttpOnly", cookie.includes("HttpOnly")); + check("no viaja a sitios de terceros", cookie.includes("SameSite=Lax")); + check("con https va marcada como segura", cookie.includes("Secure")); + check("sin https, no (si no, en local no se guarda)", !cookieDeSesion("t", false).includes("Secure")); + check("y al salir se vacía", cookieBorrada(true).includes("Max-Age=0")); + } + + console.log("\n8. Leer la sesión de la cabecera de cookies"); + { + check("sola", tokenDeCookie(`${COOKIE_SESION}=abc123`) === "abc123"); + check( + "entre otras", + tokenDeCookie(`otra=1; ${COOKIE_SESION}=abc123; multi_room=sala-1`) === "abc123", + ); + check("sin cabecera", tokenDeCookie(undefined) === undefined); + check("si no está", tokenDeCookie("otra=1; multi_room=sala-1") === undefined); + // La cookie del proxy del preview empieza igual de parecido; no se vale + // confundirlas. + check("no se confunde con una parecida", tokenDeCookie("multi_sesion_otra=x") === undefined); + } + + console.log("\n9. El color de alguien ya no cambia solo"); + { + // El bug que arregla: antes el color se repartía por orden de llegada, así + // que al reconectarte o cuando alguien se iba, cambiabas de color. + check("el mismo siempre", colorDe("usuario-1") === colorDe("usuario-1")); + // Con seis colores hay choques, y se aceptan: que TU color no cambie + // importa más que ser distinto del de al lado, porque el nombre va pegado. + // Lo que sí se comprueba es que reparta, no que devuelva siempre lo mismo. + const repartidos = new Set( + ["ana", "dan", "luis", "mar", "sol", "leo", "eva", "tom"].map(colorDe), + ); + check("y reparte entre varios", repartidos.size >= 3, `${repartidos.size} colores distintos`); + + const sala = salaVacia(); + const primero = addMember(sala, "s1", "Dan"); + addMember(sala, "s2", "Ana"); + addMember(sala, "s3", "Luis"); + sala.members.delete("s2"); + const dePrimeroOtraVez = addMember(sala, "s4", "Dan"); + check( + "no depende de cuántos haya en la sala", + primero.color === dePrimeroOtraVez.color, + `${primero.color} vs ${dePrimeroOtraVez.color}`, + ); + } + + console.log("\n10. Sin cuenta se entra igual"); + { + // El candado del requisito que manda sobre todo lo demás. Si esto se rompe + // algún día, es que el login dejó de ser opcional. + const sala = salaVacia(); + const anonimo = addMember(sala, "s1", "quien sea"); + check("hay miembro", anonimo.name === "quien sea"); + check("con su color", typeof anonimo.color === "string" && anonimo.color.startsWith("#")); + check("sin cuenta", anonimo.usuarioId === undefined); + check("y sin foto, que se cae a la inicial", anonimo.foto === null); + + const conCuenta = addMember(sala, "s2", "Dan", { id: "u-1", foto: "https://x/f.jpg" }); + check("y con cuenta se guarda de quién es", conCuenta.usuarioId === "u-1"); + check("con su foto", conCuenta.foto === "https://x/f.jpg"); + } + + console.log(`\n${pass} pasaron, ${fail} fallaron\n`); + process.exit(fail > 0 ? 1 : 0); +} + +main(); diff --git a/server/src/engine/proxy.ts b/server/src/engine/proxy.ts index e72ef0a..f578f8a 100644 --- a/server/src/engine/proxy.ts +++ b/server/src/engine/proxy.ts @@ -44,6 +44,7 @@ const RUTAS_DE_LA_SALA = [ "/providers", "/preview", "/socket.io", + "/auth", "/index.html", "/vite.svg", ]; diff --git a/server/src/index.ts b/server/src/index.ts index 46f67b6..b16c228 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -25,6 +25,22 @@ import { } from "./rooms.js"; import { getStorage } from "./storage/index.js"; import { setCredential, getCredential, clearCredential } from "./keys.js"; +import { + aUsuarioPublico, + consumirState, + cookieBorrada, + cookieDeSesion, + credencialDeGoogle, + cuandoExpira, + datosDelToken, + intercambiarCodigo, + nuevoIdDeUsuario, + nuevoState, + nuevoTokenDeSesion, + tokenDeCookie, + urlDeAutorizacion, +} from "./cuentas.js"; +import type { StoredUsuario } from "./storage/types.js"; import { makeProvider, esProviderId, PERFILES, proveedorVe } from "./agent/providers/profiles.js"; import { guardarAdjunto, @@ -160,6 +176,154 @@ if (SIRVE_WEB) { fastify.get("/health", async () => ({ status: "ok", service: "multi-server" })); +// ── Cuentas ───────────────────────────────────────────────────────────────── +// +// Todo lo de aquí es opcional. La comprobación de sesión se hace DENTRO de cada +// handler que la necesita, con `usuarioDe(req)`, y no con un hook global a +// propósito: un hook es el mecanismo que hace fácil que alguien, más adelante, +// "proteja todo por defecto" y convierta el login en un peaje. Aquí entrar a una +// sala no pide cuenta y no la va a pedir. + +/** Si el server se sirve por https, la cookie tiene que ir marcada como segura. */ +function esSeguro(req: { headers: Record; protocol?: string }): boolean { + const reenviado = req.headers["x-forwarded-proto"]; + if (typeof reenviado === "string") return reenviado.split(",")[0].trim() === "https"; + return req.protocol === "https"; +} + +/** Quién manda esta petición, o null. Que no haya nadie es un caso normal. */ +async function usuarioDe(req: { headers: Record }): Promise { + const token = tokenDeCookie(req.headers.cookie as string | undefined); + if (!token) return null; + return (await getStorage()).usuarioPorSesion(token); +} + +/** A dónde vuelve Google. Tiene que coincidir con lo registrado en su consola. */ +function urlDeVuelta(req: { headers: Record; protocol: string }): string { + const host = req.headers.host as string; + const proto = esSeguro(req) ? "https" : req.protocol; + return `${proto}://${host}/auth/google/callback`; +} + +fastify.get("/auth/google", async (req, reply) => { + const cred = credencialDeGoogle(); + // Sin credenciales configuradas no hay login, y se dice. Es una capacidad que + // solo puede habilitar quien hospeda este Multi. + if (!cred) { + return reply.code(503).send({ error: "este Multi no tiene configurado el inicio de sesión" }); + } + const state = nuevoState(); + return reply.redirect(urlDeAutorizacion(cred, state, urlDeVuelta(req))); +}); + +fastify.get<{ Querystring: { code?: string; state?: string; error?: string } }>( + "/auth/google/callback", + async (req, reply) => { + const cred = credencialDeGoogle(); + if (!cred) return reply.code(503).send({ error: "el inicio de sesión no está configurado" }); + + // Que la persona le diera a "cancelar" en Google no es un error: se vuelve a + // la app como si nada, sin cuenta, que es un estado perfectamente válido. + if (req.query.error || !req.query.code) return reply.redirect("/"); + + // El state ata esta vuelta a una ida que salió de aquí. + if (!consumirState(req.query.state)) { + return reply.code(400).send({ error: "el inicio de sesión caducó, vuelve a intentar" }); + } + + const idToken = await intercambiarCodigo(cred, req.query.code, urlDeVuelta(req)); + const datos = idToken ? datosDelToken(idToken) : null; + if (!datos) return reply.code(502).send({ error: "Google no confirmó quién eres" }); + + const storage = await getStorage(); + let usuario = await storage.usuarioPorGoogleSub(datos.sub); + if (usuario) { + // Se refresca en cada entrada: si cambió su nombre o su foto en Google, + // aquí se ve, sin que nadie tenga que apretar nada. + await storage.actualizarUsuario(usuario.id, { + nombre: datos.nombre, + correo: datos.correo, + foto: datos.foto, + }); + usuario = { ...usuario, nombre: datos.nombre, correo: datos.correo, foto: datos.foto }; + } else { + usuario = { + id: nuevoIdDeUsuario(), + googleSub: datos.sub, + nombre: datos.nombre, + correo: datos.correo, + foto: datos.foto, + creadoEn: Date.now(), + }; + await storage.crearUsuario(usuario); + } + + const token = nuevoTokenDeSesion(); + await storage.crearSesion(token, usuario.id, cuandoExpira()); + reply.header("set-cookie", cookieDeSesion(token, esSeguro(req))); + // De vuelta a donde estaba: el front guardó la sala antes de mandarla aquí. + return reply.redirect("/"); + }, +); + +/** + * Quién soy. + * + * Devuelve 200 con `usuario: null` cuando no hay sesión, nunca 401: no tener + * cuenta no es un error y no tiene por qué salir en rojo en la consola de nadie. + * `configurado` le dice al front si mostrar el botón de entrar. + */ +fastify.get("/auth/yo", async (req) => { + const usuario = await usuarioDe(req); + return { + configurado: credencialDeGoogle() != null, + usuario: usuario ? aUsuarioPublico(usuario) : null, + }; +}); + +fastify.post("/auth/salir", async (req, reply) => { + const token = tokenDeCookie(req.headers.cookie as string | undefined); + if (token) await (await getStorage()).borrarSesion(token); + reply.header("set-cookie", cookieBorrada(esSeguro(req))); + return { ok: true }; +}); + +/** Las salas de quien tiene cuenta. Sin sesión, lista vacía y a otra cosa. */ +fastify.get("/auth/salas", async (req) => { + const usuario = await usuarioDe(req); + if (!usuario) return { salas: [] }; + return { salas: await (await getStorage()).salasDeUsuario(usuario.id) }; +}); + +/** + * Sube de golpe las salas que ya vivían en el navegador. + * + * Se llama al entrar, no solo al registrarse: si te haces cuenta en la laptop y + * luego entras en otra máquina donde tenías otras salas guardadas, esas también + * suben. Sin esto, tener cuenta se sentiría como empezar de cero. + */ +fastify.post<{ Body: { salas?: { id?: unknown }[] } }>("/auth/salas", async (req, reply) => { + const usuario = await usuarioDe(req); + if (!usuario) return reply.code(401).send({ error: "sin sesión" }); + + const storage = await getStorage(); + const entrantes = Array.isArray(req.body?.salas) ? req.body.salas : []; + for (const sala of entrantes.slice(0, 50)) { + if (typeof sala?.id !== "string" || !sala.id) continue; + // Las que ya no existen se caen solas: `recordarSalaDeUsuario` tiene FK + // contra rooms, así que insertar una borrada falla y se ignora. + await storage.recordarSalaDeUsuario(usuario.id, sala.id).catch(() => {}); + } + return { salas: await storage.salasDeUsuario(usuario.id) }; +}); + +fastify.delete<{ Params: { roomId: string } }>("/auth/salas/:roomId", async (req, reply) => { + const usuario = await usuarioDe(req); + if (!usuario) return reply.code(401).send({ error: "sin sesión" }); + await (await getStorage()).olvidarSalaDeUsuario(usuario.id, req.params.roomId); + return { ok: true }; +}); + // Crear sala → devuelve su id. El cliente navega a /sala/:id. fastify.post("/rooms", async () => { const room = await createRoom(); @@ -523,10 +687,26 @@ io.on("connection", (socket) => { } joinedRoom = room; socket.join(roomId); - const member = addMember(room, socket.id, name || "anónimo"); + + // La sesión viaja en la cookie del handshake, no en el payload: el cliente + // no la puede leer (es HttpOnly) y por lo tanto tampoco inventarla. + // + // Sin cuenta se entra igual, con el nombre que mandó y su color derivado de + // ese nombre. Con cuenta, el perfil pisa al nombre local, que es lo que + // espera quien se tomó la molestia de tener uno. + const usuario = await usuarioDe({ headers: socket.handshake.headers }); + const member = addMember( + room, + socket.id, + usuario?.nombre || name || "anónimo", + usuario ? { id: usuario.id, foto: usuario.foto ?? null } : undefined, + ); // Estado inicial para el que entra, con el chat que ya existía. const storage = await getStorage(); + // Con `void`: que la BD tarde no debe retrasar el `joined`. Mismo criterio + // que `say`, que emite antes de persistir. + if (usuario) void storage.recordarSalaDeUsuario(usuario.id, roomId).catch(() => {}); const history = await storage.getMessages(roomId); socket.emit("joined", { roomId, @@ -553,6 +733,9 @@ io.on("connection", (socket) => { text: m.text, anchoredTo: m.anchoredTo, adjuntos: m.adjuntos, + // Null en todo lo anterior a las cuentas y en lo de quien entra sin + // una: ahí se pinta la inicial, como se ha visto siempre. + foto: m.foto ?? null, })), }); // Avisar a los demás de la nueva presencia. @@ -616,6 +799,8 @@ io.on("connection", (socket) => { text, anchoredTo: anchor ? anchor.path : undefined, adjuntos: adjuntos.length ? adjuntos : undefined, + usuarioId: member.usuarioId, + foto: member.foto, }); // 2) ¿Es plática o una orden? El agente solo despierta si lo llaman. @@ -907,6 +1092,10 @@ async function say( text: string; anchoredTo?: string; adjuntos?: Adjunto[]; + /** Su cuenta, si la tiene. Es lo que deja pintar su foto en el historial. */ + usuarioId?: string; + /** La foto, que viaja con el mensaje para no consultarla al pintar. */ + foto?: string | null; }, ): Promise { io.to(room.id).emit("chat:message", msg); @@ -926,6 +1115,9 @@ async function say( mediaType: a.mediaType, })), createdAt: Date.now(), + // La foto NO se guarda en la fila: se resuelve al leer, para que cambiar + // de foto actualice también lo que dijiste antes. + usuarioId: msg.usuarioId ?? null, }); await storage.touchRoom(room.id); } catch (err) { @@ -1273,6 +1465,10 @@ try { // Las salas no se despiertan al arrancar: sería carísimo levantar N dev // servers. Cada una despierta cuando alguien entra (wakeRoom). const salas = await loadRoomIndex(); + // Las sesiones caducadas se barren aquí y no con un timer: un temporizador + // vivo para siempre es un recurso más que cuidar, y esto puede esperar al + // siguiente arranque sin que nadie lo note. + void getStorage().then((s) => s.borrarSesionesVencidas().catch(() => {})); const modoKeys = TEST_MOCK ? "agente simulado" : FALLBACK @@ -1281,6 +1477,9 @@ try { console.log( `Multi server en http://localhost:${PORT} (${modoKeys}, ${salas} sala(s) guardada(s))`, ); + if (!credencialDeGoogle()) { + console.log(" (sin GOOGLE_CLIENT_ID: se entra sin cuenta, que es el modo de siempre)"); + } } catch (err) { fastify.log.error(err); process.exit(1); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 696dfbc..c9b28f2 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -29,6 +29,13 @@ export interface Member { * Es un booleano a propósito: la key jamás sale del server. */ canInvoke?: boolean; + /** + * Su cuenta, si tiene. Para la enorme mayoría esto va a estar vacío SIEMPRE, + * y no es un pendiente: entrar a una sala nunca pide cuenta. + */ + usuarioId?: string; + /** Su foto de perfil. Sin ella se pinta la inicial sobre el color, como toda la vida. */ + foto?: string | null; } /** Un elemento del preview seleccionado (capturado por el inspector). */ @@ -104,6 +111,32 @@ export function colorFor(index: number): string { return COLORS[index % COLORS.length]; } +/** + * El color de alguien, derivado de una clave estable. + * + * Antes se repartía por orden de llegada (`colorFor(members.size)`), y eso hacía + * que tu color cambiara al reconectarte o cuando alguien que llegó antes se iba. + * Con cinco personas en una sala eso es justo lo que vuelve el chat ilegible: + * sigues un color y de pronto es de otra persona. + * + * Ahora sale de un hash de la clave: el id de la cuenta si la hay, y si no el + * nombre. Así también es estable para quien entra sin cuenta, que es la mayoría. + * + * A cambio, dos personas en la misma sala pueden tocarles el mismo color, cosa + * que el reparto por posición evitaba para los primeros seis. Se acepta: el + * nombre va siempre al lado, y que TU color no cambie importa más que ser + * distinto del de al lado. + */ +export function colorDe(clave: string): string { + // FNV-1a: corto, sin dependencias y bien repartido para lo que hace falta. + let hash = 2166136261; + for (let i = 0; i < clave.length; i++) { + hash ^= clave.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return COLORS[Math.abs(hash) % COLORS.length]; +} + /** * Crea una sala: genera id, crea su workspace (proyecto de prueba sembrado), * instala deps y arranca el preview. Devuelve la sala lista. @@ -477,11 +510,26 @@ export async function renameRoom(room: Room, crudo: unknown): Promise { @@ -136,8 +192,8 @@ export class SqliteStorage implements Storage { async appendMessage(m: StoredMessage): Promise { this.db .prepare( - `INSERT INTO messages (room_id, author, color, role, text, anchored_to, adjuntos, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO messages (room_id, author, color, role, text, anchored_to, adjuntos, created_at, usuario_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( m.roomId, @@ -148,14 +204,26 @@ export class SqliteStorage implements Storage { m.anchoredTo ?? null, m.adjuntos?.length ? JSON.stringify(m.adjuntos) : null, m.createdAt, + m.usuarioId ?? null, ); } + /** + * Los últimos mensajes de la sala, con la foto de quien los escribió. + * + * La foto sale de un JOIN y no de la fila: si alguien cambia su foto, sus + * mensajes viejos muestran la nueva. Es lo que hace todo el mundo y lo que la + * gente espera. El LEFT es lo que importa: la enorme mayoría de los mensajes + * no tiene cuenta detrás y tienen que salir igual. + */ async getMessages(roomId: string, limit = 200): Promise { const rows = this.db .prepare( `SELECT * FROM ( - SELECT * FROM messages WHERE room_id = ? ORDER BY id DESC LIMIT ? + SELECT m.*, u.foto AS foto_usuario + FROM messages m + LEFT JOIN usuarios u ON u.id = m.usuario_id + WHERE m.room_id = ? ORDER BY m.id DESC LIMIT ? ) ORDER BY id ASC`, ) .all(roomId, limit) as Record[]; @@ -168,6 +236,8 @@ export class SqliteStorage implements Storage { anchoredTo: r.anchored_to ? String(r.anchored_to) : undefined, adjuntos: parseAdjuntos(r.adjuntos), createdAt: Number(r.created_at), + usuarioId: r.usuario_id ? String(r.usuario_id) : null, + foto: r.foto_usuario ? String(r.foto_usuario) : null, })); } @@ -201,11 +271,116 @@ export class SqliteStorage implements Storage { } } + // ── Cuentas ───────────────────────────────────────────────────────────── + + async usuarioPorGoogleSub(googleSub: string): Promise { + const row = this.db + .prepare(`SELECT * FROM usuarios WHERE google_sub = ?`) + .get(googleSub) as Record | undefined; + return row ? toUsuario(row) : null; + } + + async crearUsuario(u: StoredUsuario): Promise { + this.db + .prepare( + `INSERT INTO usuarios (id, google_sub, nombre, correo, foto, creado_en) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run(u.id, u.googleSub, u.nombre, u.correo, u.foto ?? null, u.creadoEn); + } + + async actualizarUsuario( + id: string, + datos: { nombre: string; correo: string; foto: string | null }, + ): Promise { + this.db + .prepare(`UPDATE usuarios SET nombre = ?, correo = ?, foto = ? WHERE id = ?`) + .run(datos.nombre, datos.correo, datos.foto, id); + } + + /** + * El dueño de una sesión, comprobando de paso que no haya vencido. + * + * La expiración se filtra en el SELECT y no después: así una sesión vencida + * es indistinguible de una que no existe, y no hay forma de olvidarse de + * comprobarla en algún camino. + */ + async usuarioPorSesion(token: string): Promise { + const row = this.db + .prepare( + `SELECT u.* FROM sesiones s + JOIN usuarios u ON u.id = s.usuario_id + WHERE s.token = ? AND s.expira_en > ?`, + ) + .get(token, Date.now()) as Record | undefined; + return row ? toUsuario(row) : null; + } + + async crearSesion(token: string, usuarioId: string, expiraEn: number): Promise { + this.db + .prepare(`INSERT INTO sesiones (token, usuario_id, creada_en, expira_en) VALUES (?, ?, ?, ?)`) + .run(token, usuarioId, Date.now(), expiraEn); + } + + async borrarSesion(token: string): Promise { + this.db.prepare(`DELETE FROM sesiones WHERE token = ?`).run(token); + } + + async borrarSesionesVencidas(): Promise { + this.db.prepare(`DELETE FROM sesiones WHERE expira_en <= ?`).run(Date.now()); + } + + /** + * Las salas de alguien, de la visita más reciente hacia atrás. + * + * El JOIN contra `rooms` no es decorativo: filtra las que ya se borraron. Sin + * él la cuenta acumularía para siempre salas que no existen. + */ + async salasDeUsuario(usuarioId: string): Promise { + const rows = this.db + .prepare( + `SELECT s.room_id, s.visitada_en FROM salas_de_usuario s + JOIN rooms r ON r.id = s.room_id + WHERE s.usuario_id = ? ORDER BY s.visitada_en DESC`, + ) + .all(usuarioId) as Record[]; + return rows.map((r) => ({ + roomId: String(r.room_id), + visitadaEn: Number(r.visitada_en), + })); + } + + async recordarSalaDeUsuario(usuarioId: string, roomId: string): Promise { + this.db + .prepare( + `INSERT INTO salas_de_usuario (usuario_id, room_id, visitada_en) VALUES (?, ?, ?) + ON CONFLICT(usuario_id, room_id) DO UPDATE SET visitada_en = excluded.visitada_en`, + ) + .run(usuarioId, roomId, Date.now()); + } + + async olvidarSalaDeUsuario(usuarioId: string, roomId: string): Promise { + this.db + .prepare(`DELETE FROM salas_de_usuario WHERE usuario_id = ? AND room_id = ?`) + .run(usuarioId, roomId); + } + async close(): Promise { this.db.close(); } } +function toUsuario(r: Record): StoredUsuario { + return { + id: String(r.id), + googleSub: String(r.google_sub), + nombre: String(r.nombre), + correo: String(r.correo), + foto: r.foto == null ? null : String(r.foto), + creadoEn: Number(r.creado_en), + }; +} + /** * Los adjuntos de un mensaje, desde la columna. * diff --git a/server/src/storage/types.ts b/server/src/storage/types.ts index e3fc0f6..636f184 100644 --- a/server/src/storage/types.ts +++ b/server/src/storage/types.ts @@ -36,6 +36,47 @@ export interface StoredMessage { /** Las imágenes que venían con el mensaje. Solo los datos para mostrarlas. */ adjuntos?: { id: string; nombre: string; mediaType: string }[]; createdAt: number; + /** + * Quién lo escribió, si tenía cuenta. Null para todo lo anterior a las + * cuentas y para quien entra sin una, que es un caso normal y para siempre. + * + * Sirve para pintar su foto: el `author` es texto libre y no alcanza, porque + * dos personas distintas pueden haberse llamado igual en salas distintas. + */ + usuarioId?: string | null; + /** + * La foto de quien lo escribió, resuelta al leer (no se guarda en la fila). + * + * Se resuelve en la consulta y no se congela a propósito: si cambias tu foto, + * tus mensajes viejos muestran la nueva, que es lo que la gente espera. El + * `color` sí va congelado, y esa incoherencia es vieja y no vale una migración. + */ + foto?: string | null; +} + +/** + * Una persona con cuenta. + * + * Tener cuenta es OPCIONAL y lo seguirá siendo: se entra a cualquier sala con + * el link y nada más, como siempre. Esto es solo para quien quiere que sus + * salas le sigan entre dispositivos y que su cara y su nombre sean los mismos + * en todas. + */ +export interface StoredUsuario { + id: string; + /** El identificador de Google. Único y estable aunque cambie de correo. */ + googleSub: string; + nombre: string; + correo: string; + /** La URL de la foto que da Google. Null si su cuenta no tiene. */ + foto?: string | null; + creadoEn: number; +} + +/** Una sala que alguien con cuenta visitó, para que le siga entre dispositivos. */ +export interface SalaDeUsuario { + roomId: string; + visitadaEn: number; } export interface Storage { @@ -81,5 +122,33 @@ export interface Storage { */ listAgentIds(roomId: string): Promise; + // ── Cuentas ─────────────────────────────────────────────────────────────── + // + // Todo lo de aquí es opcional en el sentido literal: si nadie se registra, + // estas tablas quedan vacías y Multi funciona igual que siempre. + + /** El usuario de esa cuenta de Google, o null si nunca ha entrado. */ + usuarioPorGoogleSub(googleSub: string): Promise; + crearUsuario(usuario: StoredUsuario): Promise; + /** + * Refresca lo que Google nos dice de alguien que ya tenía cuenta. + * + * Se llama en cada entrada: si cambió su nombre o su foto allá, aquí se ve. + * Es más barato que un botón de "actualizar perfil" que nadie apretaría. + */ + actualizarUsuario(id: string, datos: { nombre: string; correo: string; foto: string | null }): Promise; + + /** Quién es el dueño de esta sesión, o null si no vale o ya expiró. */ + usuarioPorSesion(token: string): Promise; + crearSesion(token: string, usuarioId: string, expiraEn: number): Promise; + borrarSesion(token: string): Promise; + /** Limpia las que ya vencieron. Se llama al arrancar, no con un timer. */ + borrarSesionesVencidas(): Promise; + + /** Las salas de alguien con cuenta, de la más reciente a la más vieja. */ + salasDeUsuario(usuarioId: string): Promise; + recordarSalaDeUsuario(usuarioId: string, roomId: string): Promise; + olvidarSalaDeUsuario(usuarioId: string, roomId: string): Promise; + close(): Promise; } diff --git a/web/src/App.tsx b/web/src/App.tsx index 3455648..e416007 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,1276 +1,1386 @@ -import { useEffect, useRef, useState, useCallback } from "react"; -import type { Socket } from "socket.io-client"; -import { - connectSocket, - createRoom, - SERVER_URL, - type ChatMessage, - type Member, - type JoinedPayload, - type SelectedElement, - type CursorInfo, - type SelectionInfo, - type Agent, - type OrphanTurn, -} from "./socket.js"; -import { AgentList } from "./AgentList.js"; -import { MentionMenu } from "./MentionMenu.js"; -import { Historial } from "./Historial.js"; -import { BackCanvas, type Endpoint } from "./BackCanvas.js"; -import { KeyPanel, loadStoredCredencial, type Credencial } from "./KeyPanel.js"; -import { EnvPanel } from "./EnvPanel.js"; -import { PublicarPanel } from "./PublicarPanel.js"; -import { useTextos } from "./i18n.js"; -import { MenuSalas } from "./MenuSalas.js"; -import { recordarSala, olvidarSala, recordarNombre } from "./historial-salas.js"; -import { - prepararImagen, - imagenesDe, - esImagenAceptada, - ACEPTADOS, - type AdjuntoPendiente, -} from "./imagenes.js"; - -/** Cuántas imágenes caben en un mensaje. El server aplica el mismo tope. */ -const MAX_ADJUNTOS = 4; - -/** - * A qué ancho se mira el preview. - * - * Un solo botón las cicla en este orden, en vez de tres botones en la barra: - * ya hay siete controles ahí arriba y este se toca poco. - * - * Los anchos son los de siempre para cada clase de pantalla; lo que importa no - * es el número exacto sino cruzar los puntos donde un layout se rompe. - */ -const VISTAS = ["escritorio", "tablet", "movil"] as const; -type Vista = (typeof VISTAS)[number]; - -const ANCHO_DE_VISTA: Record = { - // Sin ancho: el preview ocupa lo que haya, como siempre. - escritorio: undefined, - tablet: "768px", - movil: "390px", -}; - -// El roomId vive en el hash de la URL: #/sala/taco-fiesta-42 -function readRoomFromHash(): string | null { - const m = window.location.hash.match(/#\/sala\/([\w-]+)/); - return m ? m[1] : null; -} - -export function App() { - const [roomId, setRoomId] = useState(readRoomFromHash()); - const [name, setName] = useState(localStorage.getItem("multi-name") ?? ""); - /** - * Si ya diste tu nombre alguna vez, no se te vuelve a preguntar. - * - * `entered` es estado de React y se pierde al recargar, así que arrancar - * siempre en `false` mandaba a la pantalla del nombre CADA vez que alguien - * refrescaba: con el nombre ya escrito y la sala ya elegida, un botón de - * "entrar" que solo estorba. Recargar es lo primero que hace la gente cuando - * algo se ve raro, así que se topaban con eso seguido. - * - * El nombre guardado es justo la señal de que esa pantalla ya cumplió su - * función. Sin nombre sí se pregunta: es la primera vez. - */ - const [entered, setEntered] = useState(() => !!localStorage.getItem("multi-name")); - - useEffect(() => { - const onHash = () => setRoomId(readRoomFromHash()); - window.addEventListener("hashchange", onHash); - return () => window.removeEventListener("hashchange", onHash); - }, []); - - /** - * Anotar la sala en la que de verdad estás, también al recargar. - * - * `recordarSala` vivía solo en el botón de entrar. Con el salto de arriba ese - * botón deja de pulsarse en la mayoría de las visitas, y sin esto la lista de - * "tus salas" se quedaría congelada en la primera vez que entraste a cada una. - * - * Sigue sin anotarse por el solo hecho de leer el hash: esto corre cuando - * `entered` ya es cierto, o sea cuando estás dentro, no cuando abriste un link - * que no llegaste a usar. - */ - useEffect(() => { - if (roomId && entered) recordarSala(roomId); - }, [roomId, entered]); - - /** - * Sin sala en la URL se entra igual, a la Sala vacía. - * - * Antes había una portada con un botón de "crear una sala", y eso hacía dos - * cosas malas: quien ya tenía salas no las veía (viven en el menú, que solo - * existe dentro), así que creaba otra; y el único camino para llegar a - * cualquier lado era crear una, aunque solo quisieras volver a la de ayer. - * Cuatro de nueve salas acabaron vacías. - * - * Ahora se cae dentro con el menú a mano, y crear es un botón más. - */ - if (!roomId) return ; - - // Hay sala pero falta decir cómo te llamas. Sigue haciendo falta para quien - // llega por un link que le pasaron: la sala necesita saber quién entró. - if (!entered) { - return ( - { - localStorage.setItem("multi-name", name || "anónimo"); - // La sala se anota en el efecto de arriba, en cuanto `entered` es - // cierto. Aquí solo se guarda el nombre y se entra. - setEntered(true); - }} - /> - ); - } - - /** - * Pantalla 3: la sala. - * - * La `key` es lo que hace que al cambiar de sala se empiece de cero. Sin - * ella React ve el mismo componente en el mismo sitio, reusa la instancia y - * conserva su estado: los mensajes, los agentes y el preview de la sala - * ANTERIOR. Al entrar a una sala con historial no se notaba, porque el - * `joined` llegaba con mensajes y pisaba lo viejo; al crear una sala nueva sí, - * porque llega vacío y nada sobrescribe. Aparecías en una sala recién creada - * leyendo la conversación de otra. - */ - return ; -} - - -function NamePrompt(props: { - roomId: string; - name: string; - setName: (n: string) => void; - onEnter: () => void; -}) { - const { t } = useTextos(); - return ( -
-
-

{t.vasAEntrar}

-

{props.roomId}

- props.setName(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && props.onEnter()} - autoFocus - /> - -
-
- ); -} - -// ── La Sala ───────────────────────────────────────────────────────────────── - -/** - * La Sala. Con `roomId` en null se ve el mismo marco pero vacío: es lo que hay - * al entrar a Multi sin haber elegido sala, con el menú y el botón de crear a - * la mano. - */ -function Sala({ roomId, name }: { roomId: string | null; name: string }) { - const { t } = useTextos(); - const [members, setMembers] = useState([]); - const [messages, setMessages] = useState([]); - const [previewReady, setPreviewReady] = useState(false); - /** Por dónde va el arranque del preview. null = no está arrancando. */ - const [arrancando, setArrancando] = useState<"contenedor" | "dependencias" | "servidor" | null>(null); - const [draft, setDraft] = useState(""); - /** - * El nombre de la sala, o null si nadie la ha nombrado (ahí se ve el id). - * Es de la sala, así que llega en el `joined` y cambia para todos a la vez. - */ - const [nombre, setNombre] = useState(null); - const [editandoNombre, setEditandoNombre] = useState(false); - const [creandoSala, setCreandoSala] = useState(false); - /** Qué dice el botón de descargar ahora mismo. null = su texto normal. */ - const [zipAviso, setZipAviso] = useState(null); - /** - * Por dónde va la publicación, o null si no hay ninguna. - * - * Es de la SALA, no de quien apretó: llega en el `joined` y por socket, así - * que quien entre a media publicación ve el progreso igual que los demás. - */ - const [publicando, setPublicando] = useState<"compilando" | "subiendo" | null>(null); - /** - * Dónde está publicada la app, o null si nunca se publicó. - * - * Viene de la BD en el `joined`, así que sigue ahí mañana: el link es un dato - * de la sala, no un mensaje de hace rato. - */ - const [urlPublicada, setUrlPublicada] = useState(null); - /** - * Si la sala tiene algo guardado que llevarse. - * - * Se pregunta al server en vez de deducirlo de `previewReady`: lo que hace - * exportable a una sala es tener commits, no que su dev server esté arriba. - * Son cosas distintas y se separan por 20 segundos de arranque de Vite. Atarlo - * al preview hacía que un proyecto ya commiteado dijera "todavía no hay nada - * que descargar" mientras su preview levantaba, que es mentira. - */ - const [sePuedeExportar, setSePuedeExportar] = useState(false); - - const socketRef = useRef(null); - // Streaming POR AGENTE: varios pueden estar hablando a la vez. - const [streaming, setStreaming] = useState>({}); - /** - * Lo que cada agente ha ido haciendo en su turno. Se guarda la lista completa - * aunque por default solo se vea la última: cuando un turno falla a medias, - * saber por dónde iba es justo lo que hace falta, y esa información no está en - * ningún otro lado. - */ - const [toolLines, setToolLines] = useState>({}); - /** Agentes cuyo detalle de tools está expandido (click en la línea). */ - const [toolsAbiertas, setToolsAbiertas] = useState>({}); - const [agents, setAgents] = useState([]); - const [orphans, setOrphans] = useState([]); - /** Query del menú de menciones (null = cerrado). */ - const [mention, setMention] = useState(null); - /** Se incrementa cuando el historial cambia, para que el scrubber recargue. */ - const [histVersion, setHistVersion] = useState(0); - /** Se incrementa cuando cambia un archivo, para que el mapa del back recargue. */ - const [apiVersion, setApiVersion] = useState(0); - /** Qué tab del escenario se ve. */ - /** - * Qué se está viendo. "chat" solo existe en pantallas chicas, donde el chat - * no cabe al lado del preview y pasa a ser una vista más. - */ - const [tab, setTab] = useState<"chat" | "app" | "back">("app"); - /** - * A qué ancho se está viendo el preview. - * - * Es solo del que mira: cambiarlo no le mueve nada a los demás de la sala, ni - * toca la app. Se angosta el marco, no el proyecto. - */ - const [vista, setVista] = useState("escritorio"); - /** - * El chat colapsado deja el preview a pantalla completa. - * - * Para presentar: la gracia de Multi es ver el chat y la app a la vez, pero - * cuando enseñas el resultado el chat estorba. La barra de arriba se queda, - * asi que volver es un click. - */ - const [chatColapsado, setChatColapsado] = useState(false); - /** - * Mi API key. Se lee del navegador al montar: se configura UNA vez y sirve en - * todas las salas. null = todavía no hay (puedes entrar y platicar igual). - */ - const [miCred, setMiCred] = useState(() => loadStoredCredencial()); - /** El server rechazó la key o avisó que hace falta. */ - const [keyError, setKeyError] = useState(null); - /** Abrir el panel solo: pasa cuando intentas invocar sin key. */ - const [keyAbrir, setKeyAbrir] = useState(false); - - /** - * Las imágenes que pegaste pero todavía no mandas. - * - * Se procesan al pegarlas, no al enviar: encogerlas tarda un momento y hacerlo - * al darle a Enter dejaría el mensaje colgado sin explicación. - */ - const [pendientes, setPendientes] = useState([]); - /** Si algo salió mal con una imagen. Solo para quien la pegó. */ - const [errorAdjunto, setErrorAdjunto] = useState(null); - /** Arrastrando un archivo encima del chat. */ - const [arrastrando, setArrastrando] = useState(false); - - // Modo inspect activo (para seleccionar elementos del preview). - const [inspect, setInspect] = useState(false); - // MI selección local (la que se ancla al mandar mensaje) — cuidado 2. - const [mySelection, setMySelection] = useState(null); - // Cursores de OTROS (socketId → info). - const [cursors, setCursors] = useState>({}); - // Selecciones de OTROS (socketId → info), para dibujar sus outlines. - const [selections, setSelections] = useState>({}); - - const iframeRef = useRef(null); - const escenarioRef = useRef(null); - const inputRef = useRef(null); - /** El escondido que abre el botón de adjuntar. */ - const fileInputRef = useRef(null); - - // El iframe apunta al PROXY del server (que inyecta el inspector), no al dev server directo. - const previewSrc = `${SERVER_URL}/preview/${roomId}`; - // Origen del proxy, para validar postMessage — cuidado 1. - const proxyOrigin = new URL(SERVER_URL).origin; - - useEffect(() => { - // Sin sala no hay a qué conectarse: la Sala vacía es solo el marco con el - // menú y el botón de crear. - if (!roomId) return; - - const socket = connectSocket(); - socketRef.current = socket; - - socket.on("connect", () => { - socket.emit("join", { roomId, name }); - // La key ya configurada viaja sola: no se pide de nuevo en cada sala. - const guardada = loadStoredCredencial(); - if (guardada) socket.emit("auth:key", guardada); - }); - - socket.on("joined", (p: JoinedPayload) => { - setMembers(p.members); - setNombre(p.nombre ?? null); - recordarNombre(roomId, p.nombre ?? null); - setPublicando(p.publicando ?? null); - setUrlPublicada(p.urlPublicada ?? null); - if (p.previewUrl) setPreviewReady(true); - if (p.agents) setAgents(p.agents); - if (p.orphanTurns?.length) setOrphans(p.orphanTurns); - // El chat que ya existía en la sala (sobrevivió al reinicio). - if (p.messages?.length) setMessages(p.messages); - // Llegaste mientras se levantaba: el evento de etapa ya pasó. - // - // Solo si NO hay preview todavía. El arranque puede haber terminado antes - // de que entraras (la sala se despierta sola al primer request), y entonces - // el `preview:ready` ya pasó y no vuelve: sin esta condición el spinner se - // quedaba girando encima de un preview que sí existía. - if (p.previewArrancando && !p.previewUrl) setArrancando("servidor"); - }); - socket.on("presence", ({ members }: { members: Member[] }) => setMembers(members)); - - // Alguien de la sala le cambió el nombre: se ve al momento en la cabecera - // de todos, sin recargar. Quién fue sale en el chat. - socket.on("room:renamed", ({ nombre }: { nombre: string | null }) => { - setNombre(nombre); - recordarNombre(roomId, nombre); - }); - - // La publicación la ve toda la sala. El link y los fallos llegan además al - // chat, así que aquí solo se mueve el estado del botón. - socket.on("deploy:progreso", ({ etapa }: { etapa: "compilando" | "subiendo" }) => - setPublicando(etapa), - ); - socket.on("deploy:listo", ({ url }: { url: string }) => { - setPublicando(null); - setUrlPublicada(url); - }); - socket.on("deploy:fallo", () => setPublicando(null)); - socket.on("preview:ready", () => { - setPreviewReady(true); - setArrancando(null); - }); - socket.on("preview:arrancando", ({ etapa }: { etapa: "contenedor" | "dependencias" | "servidor" }) => - setArrancando(etapa), - ); - // El arranque terminó sin preview: la sala sigue vacía o algo falló. Se quita - // el spinner y vuelve el mensaje de "pídele a un agente que arranque el - // proyecto", que es lo accionable. - socket.on("preview:sin-arranque", () => setArrancando(null)); - socket.on("agents", ({ agents }: { agents: Agent[] }) => setAgents(agents)); - // Hay un punto nuevo en la línea de tiempo (commit, revert o bookmark). - socket.on("history:new", () => setHistVersion((v) => v + 1)); - socket.on("history:changed", () => setHistVersion((v) => v + 1)); - socket.on("orphans", ({ turns }: { turns: OrphanTurn[] }) => setOrphans(turns)); - // Un archivo cambió: el contrato front/back pudo haberse movido. El mismo - // canal de tiempo real que alimenta el preview alimenta el semáforo. - socket.on("file:changed", () => setApiVersion((v) => v + 1)); - - socket.on("chat:message", (m: ChatMessage) => { - setMessages((prev) => [...prev, m]); - // Al llegar el mensaje final de UN agente, limpiar SU streaming (no el de otros). - if (m.role === "agent") { - setStreaming((prev) => { - const n = { ...prev }; - delete n[m.from]; - return n; - }); - setToolLines((prev) => { - const n = { ...prev }; - delete n[m.from]; - return n; - }); - setToolsAbiertas((prev) => { - const n = { ...prev }; - delete n[m.from]; - return n; - }); - } - }); - // Cada delta trae el agentId: se acumula en el mensaje de ESE agente. - socket.on("agent:delta", ({ agentId, text }: { agentId: string; text: string }) => - setStreaming((p) => ({ ...p, [agentId]: (p[agentId] ?? "") + text })), - ); - socket.on("agent:tool", ({ agentId, summary }: { agentId: string; summary: string }) => - setToolLines((p) => { - const previas = p[agentId] ?? []; - // Tope: un turno largo no debe crecer sin fin en memoria. - return { ...p, [agentId]: [...previas, summary].slice(-40) }; - }), - ); - - // Cursores de otros. - socket.on("cursor", (c: CursorInfo) => { - setCursors((prev) => ({ ...prev, [c.socketId]: c })); - }); - socket.on("cursor:gone", ({ socketId }: { socketId: string }) => { - setCursors((prev) => { - const n = { ...prev }; - delete n[socketId]; - return n; - }); - }); - // Selecciones de otros (broadcast). - socket.on("select", (s: SelectionInfo) => { - setSelections((prev) => { - const n = { ...prev }; - if (s.element) n[s.socketId] = s; - else delete n[s.socketId]; - return n; - }); - }); - - socket.on("error:join", ({ message }: { message: string }) => alert(message)); - - // Alguien borró esta sala mientras estabas dentro. Sin esto te quedabas - // frente a un preview que ya no responde y un chat que no manda nada, - // sin saber por qué. - socket.on("room:deleted", () => { - olvidarSala(roomId); - alert(t.salaBorrada); - window.location.hash = "#/"; - }); - - // Solo a mí: mi key faltaba o el server la rechazó. Abre el panel. - socket.on("error:key", ({ message }: { message: string }) => { - setKeyError(message); - setKeyAbrir(true); - }); - socket.on("auth:ok", () => setKeyError(null)); - - // Solo a mí: mi imagen no se pudo guardar. El mensaje tampoco salió, así que - // hay que decirlo o parecería que se envió. - socket.on("error:adjunto", ({ message }: { message: string }) => { - setErrorAdjunto(message); - }); - - return () => { - socket.disconnect(); - }; - }, [roomId, name]); - - // Escuchar mensajes del inspector (dentro del iframe) — con validación de origen. - useEffect(() => { - const onMessage = (e: MessageEvent) => { - if (e.origin !== proxyOrigin) return; // cuidado 1 - const m = e.data; - if (!m || m.source !== "multi-inspector") return; - if (m.type === "element:selected") { - const el = m.data as SelectedElement; - setMySelection(el); - socketRef.current?.emit("select", el); - } else if (m.type === "element:gone") { - // El elemento que tenía seleccionado desapareció (HMR) — cuidado edge case. - setMySelection(null); - socketRef.current?.emit("select", null); - } - }; - window.addEventListener("message", onMessage); - return () => window.removeEventListener("message", onMessage); - }, [proxyOrigin]); - - // Enviar al inspector (dentro del iframe) el estado de inspect / clear. - const postToInspector = useCallback((msg: object) => { - iframeRef.current?.contentWindow?.postMessage({ source: "multi-parent", ...msg }, proxyOrigin); - }, [proxyOrigin]); - - useEffect(() => { - postToInspector({ type: "inspect:set", value: inspect }); - }, [inspect, postToInspector]); - - // Broadcast de mi cursor sobre el escenario (throttled ~40ms). - useEffect(() => { - const el = escenarioRef.current; - if (!el) return; - let last = 0; - const onMove = (e: MouseEvent) => { - const now = Date.now(); - if (now - last < 40) return; - last = now; - const r = el.getBoundingClientRect(); - socketRef.current?.emit("cursor", { x: e.clientX - r.left, y: e.clientY - r.top }); - }; - el.addEventListener("mousemove", onMove); - return () => el.removeEventListener("mousemove", onMove); - }, [previewReady]); - - /** - * Suma imágenes a las que van a salir con el próximo mensaje. - * - * El tope de 4 no es capricho: cada imagen cuesta tokens y los paga quien - * invoque al agente. Cortar aquí es más honesto que dejar mandar diez y que la - * factura aparezca después. - */ - const agregarImagenes = async (files: File[]) => { - if (files.length === 0) return; - setErrorAdjunto(null); - const sitio = MAX_ADJUNTOS - pendientes.length; - if (sitio <= 0) { - setErrorAdjunto(t.maxImagenes(MAX_ADJUNTOS)); - return; - } - try { - const listas = await Promise.all(files.slice(0, sitio).map(prepararImagen)); - setPendientes((prev) => [...prev, ...listas]); - if (files.length > sitio) setErrorAdjunto(t.maxImagenes(MAX_ADJUNTOS)); - } catch (err) { - setErrorAdjunto(err instanceof Error ? err.message : t.imagenNoSePudo); - } - }; - - const send = () => { - const text = draft.trim(); - // Mandar solo una imagen, sin escribir nada, es un mensaje legítimo. - if (!text && pendientes.length === 0) return; - // Anclar MI selección local al mensaje (cuidado 2/3/4). - socketRef.current?.emit("chat", { - text, - anchor: mySelection, - adjuntos: pendientes.length - ? pendientes.map((p) => ({ nombre: p.nombre, mediaType: p.mediaType, data: p.data })) - : undefined, - }); - setDraft(""); - setPendientes([]); - setMention(null); - if (mySelection) { - setMySelection(null); - postToInspector({ type: "selection:clear" }); // cuidado 4 - setInspect(false); - } - }; - - /** - * Anclar un endpoint al chat. A diferencia del anclaje del preview (que manda - * un SelectedElement del DOM), aquí se redacta el pedido en el borrador: el - * usuario lo lee, lo edita si quiere, y decide cuándo mandarlo. - */ - const anclarEndpoint = (e: Endpoint) => { - const donde = e.calls[0] ? ` (el front lo llama desde ${e.calls[0].file})` : ""; - const texto = - e.status === "faltante" - ? `@agente crea el endpoint ${e.method} ${e.path}${donde}` - : `@agente sobre el endpoint ${e.method} ${e.path}: `; - setDraft(texto); - setTab("app"); - inputRef.current?.focus(); - }; - - // Menú de menciones: se abre al escribir "@" al inicio de una palabra. - const onDraftChange = (value: string) => { - setDraft(value); - const m = value.match(/(?:^|\s)@([a-z0-9-]*)$/i); - setMention(m ? m[1] : null); - }; - - const pickMention = (name: string) => { - setDraft((d) => d.replace(/(?:^|\s)@([a-z0-9-]*)$/i, (full) => `${full.startsWith(" ") ? " " : ""}@${name} `)); - setMention(null); - }; - - /** El humano decide qué hacer con el trabajo que quedó a medias por un crash. */ - const resolveOrphans = (action: "keep" | "revert") => { - socketRef.current?.emit("orphans:resolve", { action }); - setOrphans([]); - }; - - const copyLink = () => navigator.clipboard.writeText(window.location.href); - - /** - * Publicar la app de la sala. - * - * No espera a que termine: el server responde en cuanto arranca y lo demás - * llega por socket. Un build tarda minutos, y una petición colgada ese rato se - * moriría por timeout antes de contar nada. - */ - const publicar = async () => { - if (!roomId) return; - setPublicando("compilando"); // respuesta inmediata; el server confirma en seguida - try { - const r = await fetch(`${SERVER_URL}/rooms/${roomId}/publicar`, { method: "POST" }); - if (!r.ok) { - const d = (await r.json().catch(() => null)) as { error?: string } | null; - setPublicando(null); - alert(d?.error ?? t.publicarFallo); - } - } catch { - setPublicando(null); - alert(t.publicarFallo); - } - }; - - /** Crear otra sala y entrar a ella. El hash es lo que cambia de sala. */ - const crearSala = async () => { - setCreandoSala(true); - try { - window.location.hash = `#/sala/${await createRoom()}`; - } catch (e) { - alert(t.noSePudoCrear + String(e)); - } finally { - setCreandoSala(false); - } - }; - - /** - * Guardar el nombre nuevo de la sala. - * - * No se pinta aquí lo que quedó: se manda al server y se espera su - * `room:renamed`, que llega igual para todos. Así el que renombra ve - * exactamente lo mismo que sus compas, recortes y espacios incluidos. - */ - const guardarNombre = (valor: string) => { - setEditandoNombre(false); - if (valor.trim() === (nombre ?? "")) return; // no cambió: nada que mandar - socketRef.current?.emit("room:rename", { nombre: valor }); - }; - - /** - * Bajar el proyecto de la sala como .zip. - * - * El zip sale del último punto guardado (el último turno cerrado), no del - * disco. Si hay trabajo a medias se DICE antes de bajarlo, en vez de entregar - * en silencio algo distinto de lo que se está viendo en el preview. - * - * La descarga va por un y no por fetch: así la maneja el navegador con su - * propia barra de progreso, y un proyecto grande no se carga entero en - * memoria de la pestaña. - */ - const descargarZip = async () => { - setZipAviso(t.preparandoZip); - try { - const r = await fetch(`${SERVER_URL}/rooms/${roomId}/export/estado`); - const estado = (await r.json()) as { hayCommits: boolean; cambiosSinCommitear: boolean }; - - if (!estado.hayCommits) { - setZipAviso(t.zipSalaVacia); - return; - } - - const a = document.createElement("a"); - a.href = `${SERVER_URL}/rooms/${roomId}/export`; - a.download = `${roomId}.zip`; - a.click(); - - setZipAviso(estado.cambiosSinCommitear ? t.zipTrabajoSinGuardar : null); - } catch { - setZipAviso(t.zipFallo); - } - }; - - // El aviso del botón se borra solo: es un mensaje de paso, no un estado en el - // que la sala se quede. - useEffect(() => { - if (!zipAviso || zipAviso === t.preparandoZip) return; - const id = setTimeout(() => setZipAviso(null), 4000); - return () => clearTimeout(id); - }, [zipAviso, t.preparandoZip]); - - /** - * Si la sala ya tiene algo que llevarse. Se consulta al entrar y cada vez que - * aparece un punto nuevo en la línea de tiempo (`histVersion`), que es justo - * cuando una sala vacía deja de estarlo. - */ - useEffect(() => { - // Sin sala no hay nada que preguntar: la URL salía con "null" dentro y el - // server contestaba 404 en cada visita a la Sala vacía. - if (!roomId) { - setSePuedeExportar(false); - return; - } - - let cancelado = false; - fetch(`${SERVER_URL}/rooms/${roomId}/export/estado`) - .then((r) => (r.ok ? r.json() : null)) - .then((estado: { hayCommits: boolean } | null) => { - if (!cancelado && estado) setSePuedeExportar(estado.hayCommits); - }) - .catch(() => { - // Sin respuesta no se apaga el botón: que el server tarde en contestar no - // significa que la sala esté vacía, y dejarlo apagado por eso es justo el - // bug que se está arreglando. Si de verdad no hay nada, el 409 lo dirá. - }); - return () => { - cancelado = true; - }; - }, [roomId, histVersion]); - - return ( - // `ver-*` es lo que el CSS usa en móvil para decidir qué se muestra. En - // escritorio se ignora: ahí el chat y el preview conviven en dos columnas. -
- {/* Chat izquierda */} - - - {/* Escenario derecha */} -
-
- - -
- {members.map((m) => ( -
- {m.name.slice(0, 1).toUpperCase()} -
- ))} - {/* La `key` cambia cuando el server pide la API key: remonta el - panel para que se abra solo en ese momento. */} - { - socketRef.current?.emit("auth:key", c); - setMiCred(c); - setKeyAbrir(false); - }} - onOlvidar={() => { - socketRef.current?.emit("auth:forget"); - setMiCred(null); - setKeyError(null); - }} - /> - - - {/* Las variables son del proyecto de la sala, así que sin sala no - hay dónde escribirlas. */} - {roomId && } - {/* Sin sala no hay link que compartir: copiaría la URL pelada. */} - -
-
- -
- {/* Solo en pantallas chicas: ahí el chat no cabe al lado del preview, - así que pasa a ser una pestaña más. En escritorio se esconde por - CSS, porque ahí el chat vive en su columna y no es una vista. */} - - - - - {/* A qué ancho se ve el preview. Un solo botón que cicla, y no tres, - porque se toca poco y la barra de arriba ya va llena. Solo aparece - con la app a la vista: en el back no hay nada que redimensionar. */} - {tab === "app" && ( - - )} -
- - {tab === "back" && roomId && ( -
- -
- )} - -
- {previewReady ? ( - <> - {/* El ancho va en el marco, no en el iframe, y el iframe NUNCA se - desmonta: recargarlo perdería el estado de la app (formularios - a medias, en qué pantalla ibas) y costaría segundos. */} -
-