fix: seguridad de pagos y accesos, integración PayPal y Coolify ampliado
Seguridad (crítico): - Los webhooks de Bold y dLocal solo validaban la firma si el atacante la enviaba: sin cabecera se aceptaba cualquier payload. Ahora es obligatoria. - GET /pago-exitoso marcaba contratos como pagados leyendo un query param del navegador. Ahora solo muestra estado; la confirmación la hace la verificación contra la API de la pasarela o el webhook firmado. - /uploads se servía como estático público: se descargaban RUTs, facturas y entregables sabiendo la ruta. Ahora exige sesión. - Los secretos JWT no se podían sobreescribir por entorno (faltaba el tag env:) y su valor estaba en el repo, permitiendo firmarse una sesión de admin. Ahora son configurables y el arranque se detiene si siguen con el valor publicado. - .env y session.db salen del control de versiones. - Query Runner, gestión de usuarios/roles/módulos y seeds quedan restringidos a administradores; antes bastaba con tener sesión. Pasarelas de pago: - dLocal generaba enlaces que nunca se reconciliaban: mandaba el ID numérico en vez de "contrato-N", la URL de retorno apuntaba a la API de dLocal y nunca se enviaba notification_url, así que su webhook jamás se disparaba. - PayPal solo tenía pantalla de configuración. Se implementa el servicio completo (OAuth, orden, captura, verificación de webhook) y queda seleccionable como pasarela. - La moneda estaba fija en COP: un contrato en USD generaba un cobro por esa cifra en pesos. Contratos: - pago_confirmado nunca volvía a false, así que el segundo ciclo de renovación no se cobraba aunque el cliente pagara. Se reinicia al generar enlace nuevo. - Los contratos vencidos nunca cambiaban de estado y recibían correo a diario de forma indefinida; ahora se cierran tras 30 días de gracia. Otros: - Coolify: coolifyCall ignoraba el status HTTP y reportaba errores como éxito. El agente pasa de 10 a cobertura completa (servicios, bases de datos, variables de entorno, proyectos, equipos y recursos de servidor). - SeedBalanceData ya no corre en cada arranque (recreaba transacciones borradas); ahora se invoca con SEED_BALANCE=1. - Los seeds dejan de devolver permisos revocados en cada despliegue. - Timeouts en las llamadas HTTP a Telegram y dLocal que podían colgarse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5607836261
commit
c8e5afceb2
@@ -97,44 +97,68 @@ func agentTools() []agentTool {
|
||||
tool("sistema_info", "Información general del sistema y recursos disponibles.", obj(nil, nil)),
|
||||
|
||||
// ── Coolify ──────────────────────────────────────────────────────────
|
||||
tool("coolify_instancias", "Lista las instancias de Coolify configuradas.", obj(nil, nil)),
|
||||
tool("coolify_apps", "Lista las aplicaciones de una instancia Coolify.",
|
||||
obj(map[string]agentToolParam{"config_id": num("ID de la instancia Coolify")}, []string{"config_id"})),
|
||||
tool("coolify_servicios", "Lista los servicios (stacks) de una instancia Coolify.",
|
||||
obj(map[string]agentToolParam{"config_id": num("ID de la instancia Coolify")}, []string{"config_id"})),
|
||||
tool("coolify_servidores", "Lista los servidores de una instancia Coolify.",
|
||||
obj(map[string]agentToolParam{"config_id": num("ID de la instancia Coolify")}, []string{"config_id"})),
|
||||
tool("coolify_deploy", "Despliega una aplicación en Coolify.",
|
||||
tool("coolify_instancias", "Lista las instancias de Coolify configuradas. Úsala primero para saber qué config_id existe si el usuario no lo especifica.", obj(nil, nil)),
|
||||
tool("coolify_listar", "Lista recursos de una instancia Coolify: aplicaciones, servicios, bases de datos, servidores, proyectos, deployments recientes o miembros del equipo.",
|
||||
obj(map[string]agentToolParam{
|
||||
"config_id": num("ID de la instancia Coolify"),
|
||||
"uuid": str("UUID de la aplicación"),
|
||||
"force": agentToolParam{Type: "boolean", Description: "Forzar rebuild desde cero"},
|
||||
}, []string{"config_id", "uuid"})),
|
||||
tool("coolify_restart", "Reinicia una aplicación en Coolify.",
|
||||
"tipo": agentToolParam{
|
||||
Type: "string",
|
||||
Description: "Qué listar",
|
||||
Enum: []string{"apps", "servicios", "bases_datos", "servidores", "proyectos", "deployments", "equipo"},
|
||||
},
|
||||
}, []string{"config_id", "tipo"})),
|
||||
tool("coolify_detalle", "Muestra el detalle de un recurso concreto de Coolify por su UUID.",
|
||||
obj(map[string]agentToolParam{
|
||||
"config_id": num("ID de la instancia Coolify"),
|
||||
"tipo": agentToolParam{
|
||||
Type: "string",
|
||||
Description: "Tipo de recurso",
|
||||
Enum: []string{"app", "servicio", "base_datos", "servidor", "proyecto", "deployment"},
|
||||
},
|
||||
"uuid": str("UUID del recurso"),
|
||||
}, []string{"config_id", "tipo", "uuid"})),
|
||||
tool("coolify_accion", "Ejecuta una acción sobre una aplicación, servicio o base de datos en Coolify: iniciar, detener, reiniciar o desplegar. 'deploy' solo aplica a aplicaciones.",
|
||||
obj(map[string]agentToolParam{
|
||||
"config_id": num("ID de la instancia Coolify"),
|
||||
"tipo": agentToolParam{
|
||||
Type: "string",
|
||||
Description: "Sobre qué se ejecuta la acción",
|
||||
Enum: []string{"app", "servicio", "base_datos"},
|
||||
},
|
||||
"uuid": str("UUID del recurso"),
|
||||
"accion": agentToolParam{
|
||||
Type: "string",
|
||||
Description: "Acción a ejecutar",
|
||||
Enum: []string{"start", "stop", "restart", "deploy"},
|
||||
},
|
||||
"force": agentToolParam{Type: "boolean", Description: "Solo para deploy: forzar rebuild desde cero sin usar caché"},
|
||||
}, []string{"config_id", "tipo", "uuid", "accion"})),
|
||||
tool("coolify_logs", "Obtiene los logs recientes de una aplicación en Coolify. Útil para diagnosticar por qué algo falla.",
|
||||
obj(map[string]agentToolParam{
|
||||
"config_id": num("ID de la instancia Coolify"),
|
||||
"uuid": str("UUID de la aplicación"),
|
||||
}, []string{"config_id", "uuid"})),
|
||||
tool("coolify_stop", "Detiene una aplicación en Coolify.",
|
||||
tool("coolify_deployments", "Lista los deployments recientes de una aplicación, con su estado (para saber si un despliegue terminó bien).",
|
||||
obj(map[string]agentToolParam{
|
||||
"config_id": num("ID de la instancia Coolify"),
|
||||
"uuid": str("UUID de la aplicación"),
|
||||
}, []string{"config_id", "uuid"})),
|
||||
tool("coolify_start", "Inicia una aplicación detenida en Coolify.",
|
||||
tool("coolify_envs", "Lista las variables de entorno de una aplicación o servicio en Coolify. Los valores pueden contener secretos: nunca los repitas completos en la respuesta salvo que el usuario lo pida explícitamente.",
|
||||
obj(map[string]agentToolParam{
|
||||
"config_id": num("ID de la instancia Coolify"),
|
||||
"uuid": str("UUID de la aplicación"),
|
||||
}, []string{"config_id", "uuid"})),
|
||||
tool("coolify_logs", "Obtiene los logs de una aplicación en Coolify.",
|
||||
"tipo": agentToolParam{Type: "string", Description: "Tipo de recurso", Enum: []string{"app", "servicio"}},
|
||||
"uuid": str("UUID del recurso"),
|
||||
}, []string{"config_id", "tipo", "uuid"})),
|
||||
tool("coolify_servidor_info", "Consulta información de un servidor de Coolify: recursos desplegados, dominios configurados, o valida su conexión.",
|
||||
obj(map[string]agentToolParam{
|
||||
"config_id": num("ID de la instancia Coolify"),
|
||||
"uuid": str("UUID de la aplicación"),
|
||||
}, []string{"config_id", "uuid"})),
|
||||
tool("coolify_deployments", "Lista los deployments recientes de una aplicación.",
|
||||
obj(map[string]agentToolParam{
|
||||
"config_id": num("ID de la instancia Coolify"),
|
||||
"uuid": str("UUID de la aplicación"),
|
||||
}, []string{"config_id", "uuid"})),
|
||||
"uuid": str("UUID del servidor"),
|
||||
"info": agentToolParam{
|
||||
Type: "string",
|
||||
Description: "Qué consultar",
|
||||
Enum: []string{"recursos", "dominios", "validar"},
|
||||
},
|
||||
}, []string{"config_id", "uuid", "info"})),
|
||||
|
||||
// ── Facturas ─────────────────────────────────────────────────────────
|
||||
tool("listar_facturas", "Lista facturas. Puede filtrar por estado: pendiente, pagada, vencida, cancelada.",
|
||||
@@ -420,55 +444,118 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
|
||||
}
|
||||
return out, nil
|
||||
|
||||
case "coolify_apps", "coolify_servicios", "coolify_servidores",
|
||||
"coolify_logs", "coolify_deployments":
|
||||
configID := uint(getInt("config_id", 0))
|
||||
if configID == 0 {
|
||||
return nil, fmt.Errorf("config_id requerido")
|
||||
}
|
||||
cfg, err := models.GetCoolifyConfigByID(configID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("instancia Coolify #%d no encontrada", configID)
|
||||
}
|
||||
endpoint := map[string]string{
|
||||
"coolify_apps": "/applications",
|
||||
"coolify_servicios": "/services",
|
||||
"coolify_servidores": "/servers",
|
||||
}[name]
|
||||
if endpoint == "" {
|
||||
uuid := getStr("uuid")
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("uuid requerido")
|
||||
}
|
||||
endpoint = map[string]string{
|
||||
"coolify_logs": "/applications/" + uuid + "/logs",
|
||||
"coolify_deployments": "/applications/" + uuid + "/deployments",
|
||||
}[name]
|
||||
}
|
||||
return coolifyCall("GET", endpoint, nil, cfg)
|
||||
|
||||
case "coolify_deploy":
|
||||
configID := uint(getInt("config_id", 0))
|
||||
uuid := getStr("uuid")
|
||||
force := getBool("force")
|
||||
if configID == 0 || uuid == "" {
|
||||
return nil, fmt.Errorf("config_id y uuid requeridos")
|
||||
}
|
||||
cfg, err := models.GetCoolifyConfigByID(configID)
|
||||
case "coolify_listar":
|
||||
cfg, err := coolifyConfigDe(getInt("config_id", 0))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ep := fmt.Sprintf("/deploy?uuid=%s&force=%v", uuid, force)
|
||||
return coolifyCall("POST", ep, nil, cfg)
|
||||
endpoint, ok := map[string]string{
|
||||
"apps": "/applications",
|
||||
"servicios": "/services",
|
||||
"bases_datos": "/databases",
|
||||
"servidores": "/servers",
|
||||
"proyectos": "/projects",
|
||||
"deployments": "/deployments",
|
||||
"equipo": "/teams/current/members",
|
||||
}[getStr("tipo")]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("tipo inválido: usa apps, servicios, bases_datos, servidores, proyectos, deployments o equipo")
|
||||
}
|
||||
return coolifyCall("GET", endpoint, nil, cfg)
|
||||
|
||||
case "coolify_restart":
|
||||
return coolifyAppAction(getInt("config_id", 0), getStr("uuid"), "restart")
|
||||
case "coolify_detalle":
|
||||
cfg, err := coolifyConfigDe(getInt("config_id", 0))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uuid := getStr("uuid")
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("uuid requerido")
|
||||
}
|
||||
base, ok := coolifyBasePorTipo(getStr("tipo"))
|
||||
if !ok {
|
||||
if getStr("tipo") == "deployment" {
|
||||
return coolifyCall("GET", "/deployments/"+uuid, nil, cfg)
|
||||
}
|
||||
return nil, fmt.Errorf("tipo inválido: usa app, servicio, base_datos, servidor, proyecto o deployment")
|
||||
}
|
||||
return coolifyCall("GET", base+"/"+uuid, nil, cfg)
|
||||
|
||||
case "coolify_stop":
|
||||
return coolifyAppAction(getInt("config_id", 0), getStr("uuid"), "stop")
|
||||
case "coolify_accion":
|
||||
cfg, err := coolifyConfigDe(getInt("config_id", 0))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uuid := getStr("uuid")
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("uuid requerido")
|
||||
}
|
||||
tipo := getStr("tipo")
|
||||
accion := getStr("accion")
|
||||
|
||||
case "coolify_start":
|
||||
return coolifyAppAction(getInt("config_id", 0), getStr("uuid"), "start")
|
||||
if accion == "deploy" {
|
||||
if tipo != "app" {
|
||||
return nil, fmt.Errorf("'deploy' solo aplica a aplicaciones; para %s usa start, stop o restart", tipo)
|
||||
}
|
||||
return coolifyCall("POST", fmt.Sprintf("/deploy?uuid=%s&force=%v", uuid, getBool("force")), nil, cfg)
|
||||
}
|
||||
if accion != "start" && accion != "stop" && accion != "restart" {
|
||||
return nil, fmt.Errorf("acción inválida: usa start, stop, restart o deploy")
|
||||
}
|
||||
base, ok := coolifyBasePorTipo(tipo)
|
||||
if !ok || tipo == "servidor" || tipo == "proyecto" {
|
||||
return nil, fmt.Errorf("tipo inválido para una acción: usa app, servicio o base_datos")
|
||||
}
|
||||
return coolifyCall("GET", fmt.Sprintf("%s/%s/%s", base, uuid, accion), nil, cfg)
|
||||
|
||||
case "coolify_logs", "coolify_deployments":
|
||||
cfg, err := coolifyConfigDe(getInt("config_id", 0))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uuid := getStr("uuid")
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("uuid requerido")
|
||||
}
|
||||
sufijo := "/logs"
|
||||
if name == "coolify_deployments" {
|
||||
sufijo = "/deployments"
|
||||
}
|
||||
return coolifyCall("GET", "/applications/"+uuid+sufijo, nil, cfg)
|
||||
|
||||
case "coolify_envs":
|
||||
cfg, err := coolifyConfigDe(getInt("config_id", 0))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uuid := getStr("uuid")
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("uuid requerido")
|
||||
}
|
||||
base, ok := coolifyBasePorTipo(getStr("tipo"))
|
||||
if !ok || (getStr("tipo") != "app" && getStr("tipo") != "servicio") {
|
||||
return nil, fmt.Errorf("tipo inválido: las variables de entorno solo se consultan de app o servicio")
|
||||
}
|
||||
return coolifyCall("GET", base+"/"+uuid+"/envs", nil, cfg)
|
||||
|
||||
case "coolify_servidor_info":
|
||||
cfg, err := coolifyConfigDe(getInt("config_id", 0))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uuid := getStr("uuid")
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("uuid requerido")
|
||||
}
|
||||
sufijo, ok := map[string]string{
|
||||
"recursos": "/resources",
|
||||
"dominios": "/domains",
|
||||
"validar": "/validate",
|
||||
}[getStr("info")]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("info inválida: usa recursos, dominios o validar")
|
||||
}
|
||||
return coolifyCall("GET", "/servers/"+uuid+sufijo, nil, cfg)
|
||||
|
||||
// ── Clientes ───────────────────────────────────────────────────────────
|
||||
case "listar_facturas":
|
||||
@@ -1001,6 +1088,23 @@ func coolifyCall(method, endpoint string, body []byte, cfg *models.CoolifyConfig
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
|
||||
|
||||
// Coolify devuelve el detalle del error en el body; sin revisar el status,
|
||||
// un 401/404/500 se reportaría al usuario como si la acción hubiera salido bien.
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
detalle := strings.TrimSpace(string(raw))
|
||||
if len(detalle) > 300 {
|
||||
detalle = detalle[:300]
|
||||
}
|
||||
switch resp.StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return nil, fmt.Errorf("Coolify rechazó la petición (%d): el API token de la instancia es inválido o no tiene permisos", resp.StatusCode)
|
||||
case http.StatusNotFound:
|
||||
return nil, fmt.Errorf("Coolify no encontró el recurso (404) en %s — revisa el uuid", endpoint)
|
||||
default:
|
||||
return nil, fmt.Errorf("Coolify respondió %d: %s", resp.StatusCode, detalle)
|
||||
}
|
||||
}
|
||||
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return map[string]string{"raw": string(raw)}, nil
|
||||
@@ -1008,15 +1112,46 @@ func coolifyCall(method, endpoint string, body []byte, cfg *models.CoolifyConfig
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func coolifyAppAction(configIDInt int, uuid, action string) (interface{}, error) {
|
||||
if configIDInt == 0 || uuid == "" {
|
||||
return nil, fmt.Errorf("config_id y uuid requeridos")
|
||||
// coolifyConfigDe resuelve la instancia de Coolify a usar. Si no se indicó
|
||||
// config_id y solo hay una instancia activa configurada, la toma por defecto
|
||||
// para que el usuario no tenga que saberse los IDs de memoria.
|
||||
func coolifyConfigDe(configIDInt int) (*models.CoolifyConfig, error) {
|
||||
if configIDInt > 0 {
|
||||
cfg, err := models.GetCoolifyConfigByID(uint(configIDInt))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("instancia Coolify #%d no encontrada", configIDInt)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
cfg, err := models.GetCoolifyConfigByID(uint(configIDInt))
|
||||
cfgs, err := models.GetAllCoolifyConfigs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return coolifyCall("GET", "/applications/"+uuid+"/"+action, nil, cfg)
|
||||
var activas []models.CoolifyConfig
|
||||
for _, c := range cfgs {
|
||||
if c.Activo {
|
||||
activas = append(activas, c)
|
||||
}
|
||||
}
|
||||
if len(activas) == 1 {
|
||||
return &activas[0], nil
|
||||
}
|
||||
if len(activas) == 0 {
|
||||
return nil, fmt.Errorf("no hay instancias de Coolify activas configuradas")
|
||||
}
|
||||
return nil, fmt.Errorf("hay varias instancias de Coolify: indica cuál usar con config_id (consulta coolify_instancias)")
|
||||
}
|
||||
|
||||
// coolifyBasePorTipo mapea el tipo de recurso al prefijo de la API de Coolify.
|
||||
func coolifyBasePorTipo(tipo string) (string, bool) {
|
||||
base, ok := map[string]string{
|
||||
"app": "/applications",
|
||||
"servicio": "/services",
|
||||
"base_datos": "/databases",
|
||||
"servidor": "/servers",
|
||||
"proyecto": "/projects",
|
||||
}[tipo]
|
||||
return base, ok
|
||||
}
|
||||
|
||||
// ─── Tipos Anthropic ─────────────────────────────────────────────────────────
|
||||
@@ -1341,7 +1476,7 @@ COMPORTAMIENTO:
|
||||
- Para listar datos, muestra los más relevantes en formato legible
|
||||
- Los valores monetarios son en COP (pesos colombianos)
|
||||
- Si una herramienta falla, explica el error y sugiere alternativas
|
||||
- Para acciones en Coolify, primero usa coolify_instancias para saber qué IDs hay disponibles si el usuario no lo especifica
|
||||
- En Coolify: si solo hay una instancia activa se usa automáticamente, no hace falta preguntar por config_id; si hay varias, consulta coolify_instancias y pregunta cuál. Para actuar sobre algo necesitas su UUID: búscalo con coolify_listar por nombre antes de llamar a coolify_accion. Reiniciar/desplegar es rutinario, hazlo directo; detener algo en producción sí confírmalo antes. Si un despliegue falla, revisa coolify_logs y coolify_deployments para explicar la causa concreta
|
||||
- Cuando el mensaje empiece con "[Documento adjunto recibido: ...]", el usuario acaba de enviar un PDF o foto por Telegram y puedes leerlo directamente (está adjunto a este mismo mensaje, no es solo un nombre de archivo). Decide primero de qué se trata el archivo antes de guardarlo:
|
||||
· Si es una factura/cuenta de cobro → identifica cliente, monto y número, resuelve el cliente_id con listar_clientes, y llama a adjuntar_factura
|
||||
· Si es un documento de un proyecto (contrato, orden de servicio, entregable, etc.) → identifica a qué proyecto corresponde (usa listar_proyectos si hace falta) y llama a adjuntar_documento_proyecto
|
||||
|
||||
Reference in New Issue
Block a user