diff --git a/pkg/models/umind_agente.go b/pkg/models/umind_agente.go index c8274cb..3e32e85 100644 --- a/pkg/models/umind_agente.go +++ b/pkg/models/umind_agente.go @@ -70,3 +70,15 @@ func UpdateUmindAgente(id uint, updates map[string]interface{}) error { func DeleteUmindAgente(id uint) error { return app.Http.Database.DB.Delete(&UmindAgente{}, id).Error } + +// GetUmindAgentePorSiteKey resuelve el agente por site_key SIN filtrar por +// activo, para que el llamador pueda distinguir "no existe" de "está +// apagado" y decirlo. GetUmindAgenteBySiteKey (que sí filtra) queda para +// quien solo necesita el camino feliz. +func GetUmindAgentePorSiteKey(siteKey string) (*UmindAgente, error) { + var a UmindAgente + if err := app.Http.Database.DB.Where("site_key = ?", siteKey).First(&a).Error; err != nil { + return nil, err + } + return &a, nil +} diff --git a/rest/controllers/api/umind_widget_controller.go b/rest/controllers/api/umind_widget_controller.go index d44ee81..bae9e2d 100644 --- a/rest/controllers/api/umind_widget_controller.go +++ b/rest/controllers/api/umind_widget_controller.go @@ -200,17 +200,37 @@ const umindWidgetJS = `(function () { msgsEl.scrollTop = msgsEl.scrollHeight; } + // El servidor responde los errores como {"error":true,"message":"..."} con + // un status 4xx/5xx. Antes esto se leía igual que una respuesta buena y el + // texto del error terminaba en una burbuja, como si lo hubiera dicho el + // agente: imposible distinguir "el bot contestó raro" de "el widget está + // rechazado". Ahora el motivo real va a la consola y al visitante se le + // muestra un mensaje neutro. + function leerRespuesta(r) { + return r.json().catch(function () { return {}; }).then(function (data) { + if (!r.ok || data.error) { + var motivo = data.message || ('HTTP ' + r.status); + console.error('[uMind] el asistente no pudo responder:', motivo, + '— revisá la pestaña Auditoría del agente en uMind Studio.'); + var e = new Error(motivo); + e.esDelServidor = true; + throw e; + } + return data; + }); + } + function iniciar() { sessionId = sessionStorage.getItem(storageKey) || null; fetch(apiBase + '/init') - .then(function (r) { return r.json(); }) + .then(leerRespuesta) .then(function (data) { if (!sessionId) { sessionId = data.session_id; sessionStorage.setItem(storageKey, sessionId); } if (data.nombre) panel.querySelector('#umind-title').textContent = data.nombre; aplicarColor(data.color); agregarMensaje(data.mensaje_bienvenida, 'bot'); }) - .catch(function () { agregarMensaje('No se pudo conectar el asistente.', 'bot'); }); + .catch(function () { agregarMensaje('El asistente no está disponible en este momento.', 'bot'); }); } iniciar(); // arranca ya, así el color/nombre reales están listos antes del primer click @@ -224,12 +244,18 @@ const umindWidgetJS = `(function () { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, mensaje: texto }) }) - .then(function (r) { return r.json(); }) + .then(leerRespuesta) .then(function (data) { if (data.session_id) { sessionId = data.session_id; sessionStorage.setItem(storageKey, sessionId); } - agregarMensaje(data.respuesta || data.message || 'No pude responder eso.', 'bot'); + agregarMensaje(data.respuesta || 'No pude responder eso.', 'bot'); }) - .catch(function () { agregarMensaje('Error de conexión, intenta de nuevo.', 'bot'); }); + .catch(function (e) { + agregarMensaje( + e && e.esDelServidor + ? 'El asistente no está disponible en este momento.' + : 'Error de conexión, intenta de nuevo.', + 'bot'); + }); } function alternar() { diff --git a/rest/middlewares/auth_umind_widget.go b/rest/middlewares/auth_umind_widget.go index 81f7aa9..032fcc4 100644 --- a/rest/middlewares/auth_umind_widget.go +++ b/rest/middlewares/auth_umind_widget.go @@ -1,6 +1,8 @@ package middlewares import ( + "fmt" + "log" "net/url" "strings" @@ -19,12 +21,33 @@ func AuthUmindWidget(c *fiber.Ctx) error { if siteKey == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": "site_key requerida"}) } - agente, err := models.GetUmindAgenteBySiteKey(siteKey) + + // Se busca SIN filtrar por activo para poder distinguir "no existe" de + // "está apagado". Estos rechazos ocurren antes del motor del agente, así + // que sin registrarlos el síntoma es "el chat de prueba anda pero el + // widget no" sin ninguna pista de por qué — que es exactamente el lazo en + // el que se puede quedar alguien diagnosticando esto. + agente, err := models.GetUmindAgentePorSiteKey(siteKey) if err != nil { + log.Printf("[UMIND_WIDGET] site_key desconocida: %q (origen %q)", siteKey, c.Get("Origin")) return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": true, "message": "sitio no encontrado"}) } + if !agente.Activo { + models.RegistrarEventoUmind(agente.ID, "error", "widget", + "El widget fue rechazado porque el agente está inactivo", + "Activá el agente para que el widget vuelva a responder. El chat de prueba sí funciona con un agente inactivo, por eso la diferencia.") + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": true, "message": "sitio no encontrado"}) + } + tenant, err := models.GetUmindTenantByID(agente.TenantID) - if err != nil || !tenant.Activo { + if err != nil { + models.RegistrarEventoUmind(agente.ID, "error", "widget", "El widget no encontró el tenant del agente", err.Error()) + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": true, "message": "sitio no encontrado"}) + } + if !tenant.Activo { + models.RegistrarEventoUmind(agente.ID, "error", "widget", + "El widget fue rechazado porque el tenant está inactivo", + fmt.Sprintf("Activá el tenant %q para que el widget vuelva a responder.", tenant.Nombre)) return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": true, "message": "sitio no encontrado"}) } @@ -34,6 +57,13 @@ func AuthUmindWidget(c *fiber.Ctx) error { } host := hostDeOrigen(origen) if !tenant.DominioPermitido(host) { + detalle := fmt.Sprintf("Llamada desde %q. Dominios permitidos del tenant: %q.", host, tenant.DominiosPermitidos) + if strings.TrimSpace(tenant.DominiosPermitidos) == "" { + detalle = fmt.Sprintf("Llamada desde %q, pero el tenant no tiene ningún dominio permitido configurado (la lista vacía no permite nada).", host) + } else if host == "" { + detalle = "La petición llegó sin Origin ni Referer, así que no se pudo verificar de qué sitio viene." + } + models.RegistrarEventoUmind(agente.ID, "error", "widget", "El widget fue rechazado por dominio no autorizado", detalle) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": true, "message": "dominio no autorizado para este sitio"}) } diff --git a/rest/middlewares/auth_umind_widget_test.go b/rest/middlewares/auth_umind_widget_test.go new file mode 100644 index 0000000..c58feff --- /dev/null +++ b/rest/middlewares/auth_umind_widget_test.go @@ -0,0 +1,56 @@ +package middlewares + +import ( + "testing" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// El widget valida el dominio; el chat de prueba no. Cuando alguien reporta +// "el chat de prueba anda pero el widget no", esta comparación es casi siempre +// la causa, así que la regla queda fijada acá. +func TestDominioPermitidoEsFailClosed(t *testing.T) { + casos := []struct { + nombre string + lista string + host string + permitido bool + }{ + {"lista vacía no permite nada", "", "ejemplo.com", false}, + {"sin host no permite", "ejemplo.com", "", false}, + {"dominio exacto", "ejemplo.com", "ejemplo.com", true}, + {"dominio distinto", "ejemplo.com", "otro.com", false}, + {"www no entra por el dominio pelado", "ejemplo.com", "www.ejemplo.com", false}, + {"www listado explícitamente", "ejemplo.com,www.ejemplo.com", "www.ejemplo.com", true}, + {"comodín cubre subdominio", "*.ejemplo.com", "app.ejemplo.com", true}, + {"comodín no cubre otro dominio", "*.ejemplo.com", "app.otro.com", false}, + {"espacios alrededor no molestan", " ejemplo.com , otro.com ", "otro.com", true}, + {"mayúsculas no molestan", "Ejemplo.COM", "ejemplo.com", true}, + } + + for _, cas := range casos { + t.Run(cas.nombre, func(t *testing.T) { + tenant := &models.UmindTenant{DominiosPermitidos: cas.lista} + if got := tenant.DominioPermitido(cas.host); got != cas.permitido { + t.Errorf("DominioPermitido(%q) con lista %q = %v, esperaba %v", + cas.host, cas.lista, got, cas.permitido) + } + }) + } +} + +// hostDeOrigen recibe el header Origin o Referer, no un host pelado. +func TestHostDeOrigen(t *testing.T) { + casos := map[string]string{ + "https://ejemplo.com": "ejemplo.com", + "https://ejemplo.com/una/pagina": "ejemplo.com", + "https://ejemplo.com:8443": "ejemplo.com", // el puerto no cuenta + "http://WWW.Ejemplo.com": "www.ejemplo.com", + "": "", + } + for entrada, esperado := range casos { + if got := hostDeOrigen(entrada); got != esperado { + t.Errorf("hostDeOrigen(%q) = %q, esperaba %q", entrada, got, esperado) + } + } +}