El widget hacía `data.respuesta || data.message` sin mirar el status, así que un 403/404/500 se pintaba en una burbuja como si lo hubiera dicho el agente. Imposible distinguir "el bot contestó raro" de "el widget está siendo rechazado", que es justo el lazo en el que se puede quedar alguien diagnosticando esto. Además los rechazos del middleware ocurren ANTES del motor, así que no dejaban rastro en ningún lado: ni en la auditoría del agente ni en el navegador. De ahí el síntoma "el chat de prueba anda pero el widget no". - El widget mira r.ok, manda el motivo real a la consola y al visitante le muestra un mensaje neutro. - AuthUmindWidget distingue las tres causas que el chat de prueba NO tiene (agente inactivo, tenant inactivo, dominio no autorizado) y las registra en la auditoría del agente con el detalle accionable — incluyendo qué dominio llamó y cuáles están permitidos. - GetUmindAgentePorSiteKey busca sin filtrar por activo, para poder decir "está apagado" en vez de "no existe". - Test del allowlist de dominios: es fail-closed y www.ejemplo.com NO matchea ejemplo.com, la causa más probable de este síntoma. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
103 lines
4.1 KiB
Go
103 lines
4.1 KiB
Go
package middlewares
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
)
|
|
|
|
// AuthUmindWidget resuelve el agente a partir de la site_key en la URL y
|
|
// valida que la petición venga de uno de los dominios permitidos del tenant
|
|
// dueño de ese agente (Origin, o Referer si el navegador no manda Origin).
|
|
// La site_key no es un secreto — cualquiera puede verla en el HTML público
|
|
// del sitio — la protección real es el chequeo de dominio, igual que una
|
|
// site key de reCAPTCHA/Analytics.
|
|
func AuthUmindWidget(c *fiber.Ctx) error {
|
|
siteKey := c.Params("site_key")
|
|
if siteKey == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": "site_key requerida"})
|
|
}
|
|
|
|
// 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 {
|
|
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"})
|
|
}
|
|
|
|
origen := c.Get("Origin")
|
|
if origen == "" {
|
|
origen = c.Get("Referer")
|
|
}
|
|
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"})
|
|
}
|
|
|
|
if origen != "" {
|
|
c.Set("Access-Control-Allow-Origin", origen)
|
|
c.Set("Vary", "Origin")
|
|
}
|
|
c.Locals("umind_agente", agente)
|
|
c.Locals("umind_tenant", tenant)
|
|
return c.Next()
|
|
}
|
|
|
|
// UmindWidgetCORS responde el preflight OPTIONS que el navegador manda antes
|
|
// del POST real (el widget corre en un dominio distinto al de la API).
|
|
func UmindWidgetCORS(c *fiber.Ctx) error {
|
|
origen := c.Get("Origin")
|
|
if origen != "" {
|
|
c.Set("Access-Control-Allow-Origin", origen)
|
|
c.Set("Vary", "Origin")
|
|
}
|
|
c.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
c.Set("Access-Control-Allow-Headers", "Content-Type")
|
|
c.Set("Access-Control-Max-Age", "600")
|
|
return c.SendStatus(fiber.StatusNoContent)
|
|
}
|
|
|
|
func hostDeOrigen(origenURL string) string {
|
|
if origenURL == "" {
|
|
return ""
|
|
}
|
|
u, err := url.Parse(origenURL)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.ToLower(u.Hostname())
|
|
}
|