diff --git a/.env b/.env deleted file mode 100755 index 1df0764..0000000 --- a/.env +++ /dev/null @@ -1,27 +0,0 @@ -#VITE_API_URL= -#VITE_WS_URL= - -APP_URL=https://admin.u-site.app - -DB_DRIVER=postgres -DB_HOST=t2yq39od6o6cgxaakw6wbu9v -DB_PORT=5432 -DB_USER=usite -DB_PASS=DXPc2nzKWurNThEaWffGFuy47b65CWV4O9X75td2uOGj3kFLimOgLppWplYbx5Fu -DB_NAME=usite - - -MAIL_HOST=smtp.hostinger.com -MAIL_USERNAME=soporte@u-s.app -MAIL_PASSWORD=Nicolas2796*+ -MAIL_ENCRYPTION=TLS -MAIL_FROM_ADDRESS=soporte@u-s.app -MAIL_FROM_NAME=soporte@u-s.app -MAIL_PORT=465 - -APP_PREFORK=false - -SESSION_DATABASE=./session.db - -HERMES_API_KEY=hk_live_usite_2026_S3cur3K3yH3rm3s - diff --git a/.gitignore b/.gitignore index 3529cfd..ed66a3d 100755 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,8 @@ main /tmp /node_modules/ *.fiber.gz -#.env +.env +.env.local .DS_Store dump.rdb #uploads @@ -17,4 +18,5 @@ stats/ pnpm-lock.yaml package-lock.json .task-project -*.db \ No newline at end of file +*.db +session.db diff --git a/SEGURIDAD_DESPLIEGUE.md b/SEGURIDAD_DESPLIEGUE.md new file mode 100644 index 0000000..bb3cc59 --- /dev/null +++ b/SEGURIDAD_DESPLIEGUE.md @@ -0,0 +1,81 @@ +# Variables de entorno requeridas + +> **Leer antes del siguiente despliegue.** El arranque falla a propósito si los +> secretos JWT siguen teniendo el valor que estuvo publicado en el repositorio. + +## 1. Secretos que hay que definir sí o sí + +`.env` y `config.yml` estuvieron versionados con credenciales reales, así que +**todo lo que aparecía ahí debe considerarse comprometido y rotarse**, no solo +sacarse del repositorio: el historial de git lo sigue conteniendo. + +Genera cada valor con `openssl rand -hex 32` y cárgalos como variables de +entorno del contenedor (en Coolify: *Environment Variables*): + +| Variable | Para qué sirve | Si no se define | +|---|---|---| +| `APP_JWT_SECRET` | Firma las cookies de sesión del panel | **El arranque se detiene** | +| `API_JWT_SECRET` | Firma los tokens de la API v1 | **El arranque se detiene** | +| `APP_KEY` | Cifra las contraseñas de SMTP e integraciones | Arranca, pero avisa en los logs | +| `ADMIN_API_KEY` | Única llave de toda la API `/api/v2` | La API v2 responde 503 | + +Al rotar `APP_JWT_SECRET` se cierran las sesiones abiertas: hay que volver a +iniciar sesión, nada más. + +### Cuidado con `APP_KEY` + +`APP_KEY` cifra las contraseñas guardadas en la base de datos. Si la cambias, +**las contraseñas cifradas con la clave anterior dejan de poder descifrarse** y +hay que volver a guardarlas desde el panel: + +- Configuración SMTP (`/app/smtp-config`) +- Credenciales de las pasarelas de pago (`/app/pasarelas`) +- Cualquier otra integración con contraseña + +Por eso el sistema solo advierte en vez de detenerse: para que elijas el momento. + +## 2. Otras credenciales a rotar + +Estaban en el `.env` versionado: + +- Contraseña de PostgreSQL +- Contraseña del correo saliente +- Cualquier token de integración (Coolify, Cloudflare, Hostinger, Telegram…) + +## 3. Webhooks de pago + +Las notificaciones ahora **exigen firma válida**. Verifica en cada proveedor que +el secreto de firma coincida con el configurado en `/app/pasarelas`: + +| Pasarela | URL del webhook | Requiere | +|---|---|---| +| Bold | `https://TU-DOMINIO/webhooks/bold` | Secret de firma en la config | +| dLocal | `https://TU-DOMINIO/webhooks/dlocal` | Secret de firma en la config | +| PayPal | `https://TU-DOMINIO/webhooks/paypal` | **Webhook ID** en la config | + +Para PayPal hay que crear el webhook en el panel de PayPal suscrito a los +eventos `CHECKOUT.ORDER.APPROVED` y `PAYMENT.CAPTURE.COMPLETED`, y pegar el +Webhook ID que devuelve en `/app/pasarelas`. Sin ese ID no se pueden verificar +las notificaciones y se rechazan. + +> PayPal no opera en pesos colombianos. Los contratos que se cobren por PayPal +> deben tener la moneda en USD (u otra soportada), o PayPal rechazará la orden. + +## 4. Datos contables de ejemplo + +`SeedBalanceData` (27 transacciones de 2026 escritas en el código) ya **no** +corre en cada arranque: si el contador editaba o borraba una, el siguiente +reinicio la recreaba y el balance quedaba duplicado. + +Para cargarla puntualmente: + +```bash +SEED_BALANCE=1 ./apiv2 -config config.yml +``` + +## 5. Archivos subidos + +`/uploads` ya no es público. Antes, sabiendo la ruta se podía descargar el RUT de +un cliente o una factura sin iniciar sesión. Ahora requiere sesión en el panel; +los clientes del portal siguen descargando sus documentos por los endpoints de +siempre, que además validan que el archivo les pertenezca. diff --git a/config/config.go b/config/config.go index ae24f3b..5d1b469 100755 --- a/config/config.go +++ b/config/config.go @@ -40,6 +40,7 @@ func (cfg *AppConfig) Setup() { fmt.Println(err) os.Exit(2) } + cfg.VerificarSecretos() cfg.Server.LoadPath() cfg.View.Load(cfg.Server.Path) cfg.Mail.View = &cfg.View @@ -94,6 +95,8 @@ func (cfg *AppConfig) LoadComponents() { func (cfg *AppConfig) LoadStatic() { cfg.Server.Static("/websocket", "./resources/views/websocket.html") + // El guard se registra antes que el estático para que corra primero. + cfg.Server.Use("/uploads", uploadsProtegidos) cfg.Server.Static("/uploads", "./uploads", fiber.Static{ ByteRange: true, }) diff --git a/config/secrets_check.go b/config/secrets_check.go new file mode 100644 index 0000000..e93b7dd --- /dev/null +++ b/config/secrets_check.go @@ -0,0 +1,64 @@ +package config + +import ( + "log" + "os" + "strings" +) + +// Valores que estuvieron publicados en el repositorio y deben considerarse +// comprometidos. Si siguen en uso, cualquiera que haya visto el repo puede +// fabricarse una sesión de administrador o descifrar las contraseñas guardadas. +const ( + jwtAppComprometido = "SECRET_APP" + jwtApiComprometido = "SECRET_API" + appKeyComprometida = "1894cde6c936a294a478cff0a9227fd276d86df6573b51af5dc59c9064edf426" +) + +// VerificarSecretos se ejecuta al arrancar. Los secretos JWT comprometidos +// abortan el arranque (permiten suplantar a cualquier usuario y el único costo +// de rotarlos es volver a iniciar sesión). La clave de cifrado solo advierte, +// porque cambiarla invalida las contraseñas de integraciones ya guardadas y eso +// requiere volver a capturarlas a mano desde el panel. +func (cfg *AppConfig) VerificarSecretos() { + var criticos []string + + if esSecretoDebil(cfg.Token.AppJwtSecret, jwtAppComprometido) { + criticos = append(criticos, "APP_JWT_SECRET") + } + if esSecretoDebil(cfg.Token.ApiJwtSecret, jwtApiComprometido) { + criticos = append(criticos, "API_JWT_SECRET") + } + + if len(criticos) > 0 { + log.Printf("[SEGURIDAD] Los siguientes secretos siguen con el valor publicado en el repositorio: %s", strings.Join(criticos, ", ")) + log.Printf("[SEGURIDAD] Con ese valor cualquiera puede firmarse una cookie de sesión y entrar como administrador.") + log.Printf("[SEGURIDAD] Define variables de entorno con valores nuevos y aleatorios antes de arrancar, por ejemplo:") + for _, nombre := range criticos { + log.Printf("[SEGURIDAD] %s=$(openssl rand -hex 32)", nombre) + } + log.Fatalf("[SEGURIDAD] Arranque abortado para no exponer el sistema. Al rotarlos, las sesiones abiertas se cierran y hay que volver a iniciar sesión.") + } + + if cfg.Server.Key == "" || cfg.Server.Key == appKeyComprometida { + log.Printf("[SEGURIDAD] APP_KEY tiene el valor por defecto publicado en el repositorio.") + log.Printf("[SEGURIDAD] Con esa clave, cualquiera con acceso a la base de datos puede descifrar las contraseñas SMTP y de integraciones.") + log.Printf("[SEGURIDAD] Define APP_KEY=$(openssl rand -hex 32) y vuelve a guardar las contraseñas de SMTP e integraciones desde el panel") + log.Printf("[SEGURIDAD] (al cambiar la clave, las contraseñas cifradas con la anterior dejan de poder descifrarse).") + } +} + +// esSecretoDebil indica si el secreto está vacío o es el valor comprometido. +func esSecretoDebil(valor, comprometido string) bool { + v := strings.TrimSpace(valor) + return v == "" || v == comprometido +} + +// SecretoDesdeEntorno lee un secreto del entorno con un valor por defecto. +// Se usa para credenciales que no deben vivir en config.yml. +func SecretoDesdeEntorno(nombre, porDefecto string) string { + if v := strings.TrimSpace(os.Getenv(nombre)); v != "" { + return v + } + return porDefecto +} diff --git a/config/token.go b/config/token.go index e10b94a..8ec9c20 100755 --- a/config/token.go +++ b/config/token.go @@ -9,13 +9,16 @@ import ( ) type Token struct { - Hash string `json:"token"` - Expire int64 `mapstructure:"JWT_EXPIRE" json:"expires_in" yaml:"expires_in"` - AppJwtSecret string `mapstructure:"APP_JWT_SECRET" yaml:"app_jwt_secret"` - ApiJwtSecret string `mapstructure:"API_JWT_SECRET" yaml:"api_jwt_secret"` + Hash string `json:"token"` + Expire int64 `mapstructure:"JWT_EXPIRE" json:"expires_in" yaml:"expires_in"` + // Los secretos JWT firman las cookies de sesión: si se filtran, cualquiera + // puede fabricarse una sesión de administrador válida. El tag env: permite + // definirlos por variable de entorno sin dejarlos escritos en config.yml. + AppJwtSecret string `mapstructure:"APP_JWT_SECRET" yaml:"app_jwt_secret" env:"APP_JWT_SECRET"` + ApiJwtSecret string `mapstructure:"API_JWT_SECRET" yaml:"api_jwt_secret" env:"API_JWT_SECRET"` } -//CreateToken authenticates the user +// CreateToken authenticates the user func (t *Token) CreateToken(c *fiber.Ctx, userID uint, secret string, expire ...int64) (*Token, error) { token := jwt.New(jwt.SigningMethodHS256) @@ -47,7 +50,7 @@ func (t *Token) CreateToken(c *fiber.Ctx, userID uint, secret string, expire ... return t, nil } -//ParseToken returns the users id or error +// ParseToken returns the users id or error func (t *Token) ParseToken(c *fiber.Ctx, secret string) (uint, error) { tokenString := c.Cookies("Verify-Rest-Token") @@ -74,12 +77,12 @@ func (t *Token) ParseToken(c *fiber.Ctx, secret string) (uint, error) { return uint(claims["id"].(float64)), nil } -//DeleteToken deletes the jwt token +// DeleteToken deletes the jwt token func (t *Token) DeleteToken(c *fiber.Ctx) { c.ClearCookie("Verify-Rest-Token") } -//RefreshToken refreshes the token +// RefreshToken refreshes the token func (t *Token) RefreshToken(c *fiber.Ctx, secret string) (*Token, error) { u, err := t.ParseToken(c, secret) diff --git a/config/uploads_guard.go b/config/uploads_guard.go new file mode 100644 index 0000000..ee10f2c --- /dev/null +++ b/config/uploads_guard.go @@ -0,0 +1,25 @@ +package config + +import "github.com/gofiber/fiber/v2" + +// UploadsGuard decide si una petición puede leer archivos de /uploads. +// Se inyecta desde main.go porque el paquete config no puede importar pkg/auth +// (sería una dependencia circular vía app). Se consulta en cada petición, no al +// registrar la ruta, así que puede definirse después de cargar la configuración. +// +// Mientras esté en nil, /uploads queda cerrado: es preferible que un archivo no +// cargue a que quede expuesto sin autenticación. +var UploadsGuard func(*fiber.Ctx) bool + +// uploadsProtegidos bloquea el acceso anónimo a los archivos subidos. +// Antes /uploads se servía como estático público: sabiendo (o adivinando) la +// ruta se podía descargar el RUT de un cliente, una factura o un entregable sin +// iniciar sesión. Los clientes del portal siguen usando los endpoints de +// descarga dedicados, que además validan que el archivo les pertenezca. +func uploadsProtegidos(c *fiber.Ctx) error { + if UploadsGuard != nil && UploadsGuard(c) { + return c.Next() + } + // 404 en vez de 401 para no confirmar si el archivo existe. + return c.Status(fiber.StatusNotFound).SendString("Not found") +} diff --git a/main.go b/main.go index 7ebf5b4..f914d3e 100755 --- a/main.go +++ b/main.go @@ -3,11 +3,15 @@ package main import ( "flag" "log" + "os" + "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/pyroscope-io/pyroscope/pkg/agent/profiler" "github.com/sujit-baniya/fiber-boilerplate/app" + "github.com/sujit-baniya/fiber-boilerplate/config" "github.com/sujit-baniya/fiber-boilerplate/migrations" + "github.com/sujit-baniya/fiber-boilerplate/pkg/auth" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" "github.com/sujit-baniya/fiber-boilerplate/pkg/services" "github.com/sujit-baniya/fiber-boilerplate/rest/routes" @@ -33,6 +37,13 @@ func main() { // Actualizar versión del servidor app.Http.Server.Version = app.Version + // Los archivos de /uploads dejan de ser públicos: solo se sirven a usuarios + // con sesión iniciada en el panel. Los clientes del portal siguen bajando sus + // documentos por los endpoints dedicados, que validan que les pertenezcan. + config.UploadsGuard = func(c *fiber.Ctx) bool { + return auth.IsLoggedIn(c) + } + // Habilitar CORS app.Http.Server.App.Use(cors.New(cors.Config{ AllowOrigins: "*", @@ -146,7 +157,14 @@ func main() { migrations.SeedShield() migrations.SeedPartnerRecursos() models.SeedContabilidad() - models.SeedBalanceData() + // SeedBalanceData carga transacciones contables reales de 2026. Ya no se + // ejecuta en cada arranque: si el contador editaba o borraba una de esas + // filas, el siguiente reinicio la recreaba y el balance quedaba duplicado. + // Para recargarla puntualmente: SEED_BALANCE=1 ./apiv2 + if os.Getenv("SEED_BALANCE") == "1" { + log.Println("[SEED] SEED_BALANCE=1 — cargando datos de balance") + models.SeedBalanceData() + } migrations.SeedContabilidadMenu() migrations.SeedWebSms() migrations.SeedUrlMonitor() diff --git a/migrations/migrate.go b/migrations/migrate.go index 63e67ad..37bffbc 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -163,6 +163,43 @@ func Migrate() { log.Println("Migration Completed...") } +// asignarSubmodulosSiFaltan agrega submódulos a un rol solo si el rol todavía no +// tiene NINGUNO de ellos. +// +// Antes los seeds hacían Append incondicional en cada arranque: si un admin le +// quitaba un módulo a un rol desde el panel, el siguiente despliegue se lo +// devolvía y la gestión de permisos era, en la práctica, imposible de sostener. +func asignarSubmodulosSiFaltan(rol *models.Roles, subs []models.Submodules) { + if rol == nil || len(subs) == 0 { + return + } + db := app.Http.Database.DB + + var ids []uint + for _, s := range subs { + if s.ID > 0 { + ids = append(ids, s.ID) + } + } + if len(ids) == 0 { + return + } + + var yaAsignados int64 + db.Table("roles_submodules"). + Where("roles_id = ? AND submodules_id IN ?", rol.ID, ids). + Count(&yaAsignados) + if yaAsignados > 0 { + // El rol ya conoce este grupo de submódulos: lo que tenga hoy es una + // decisión del administrador y no se toca. + return + } + + if err := db.Model(rol).Association("Submodules").Append(&subs); err != nil { + log.Printf("[SEED] Error asignando submódulos al rol %s: %v", rol.Name, err) + } +} + // SeedStatuspage agrega el submódulo "Statuspage" (Atlassian) al módulo "Integraciones" // y lo asigna a todos los roles. Es idempotente. func SeedStatuspage() { @@ -207,11 +244,7 @@ func SeedStatuspage() { return } for _, rol := range roles { - if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil { - log.Printf("[SEED] Error asignando submódulo Statuspage al rol '%s': %v", rol.Name, err) - } else { - log.Printf("[SEED] Submódulo Statuspage asignado al rol '%s'", rol.Name) - } + asignarSubmodulosSiFaltan(&rol, insertados) } log.Println("[SEED] Seed de Statuspage completado.") @@ -407,7 +440,7 @@ func SeedRenovaciones() { if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil { log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err) } else { - db.Model(&rolAdmin).Association("Submodules").Append(&insertados) + asignarSubmodulosSiFaltan(&rolAdmin, insertados) log.Printf("[SEED] Submódulos de Renovaciones asignados al rol 'Administrador'") } @@ -481,7 +514,7 @@ func SeedAutomatizacionIA() { if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil { log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err) } else { - db.Model(&rolAdmin).Association("Submodules").Append(&insertados) + asignarSubmodulosSiFaltan(&rolAdmin, insertados) log.Printf("[SEED] Submódulos de Automatización IA asignados al rol 'Administrador'") } @@ -555,11 +588,7 @@ func SeedIntegraciones() { return } for _, rol := range roles { - if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil { - log.Printf("[SEED] Error asignando submódulos al rol '%s': %v", rol.Name, err) - } else { - log.Printf("[SEED] Submódulos de Integraciones asignados al rol '%s'", rol.Name) - } + asignarSubmodulosSiFaltan(&rol, insertados) } log.Println("[SEED] Seed de Integraciones completado.") @@ -609,11 +638,7 @@ func SeedPasarelas() { return } for _, rol := range roles { - if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil { - log.Printf("[SEED] Error asignando submódulos al rol '%s': %v", rol.Name, err) - } else { - log.Printf("[SEED] Submódulo Pasarelas asignado al rol '%s'", rol.Name) - } + asignarSubmodulosSiFaltan(&rol, insertados) } log.Println("[SEED] Seed de Pasarelas completado.") @@ -686,11 +711,7 @@ func SeedServidores() { return } for _, rol := range roles { - if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil { - log.Printf("[SEED] Error asignando submódulos al rol '%s': %v", rol.Name, err) - } else { - log.Printf("[SEED] Submódulos de Servidores asignados al rol '%s'", rol.Name) - } + asignarSubmodulosSiFaltan(&rol, insertados) } log.Println("[SEED] Seed de Servidores completado.") @@ -757,11 +778,7 @@ func SeedAdministracion() { return } for _, rol := range roles { - if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil { - log.Printf("[SEED] Error asignando submódulos al rol '%s': %v", rol.Name, err) - } else { - log.Printf("[SEED] Submódulos de Administración asignados al rol '%s'", rol.Name) - } + asignarSubmodulosSiFaltan(&rol, insertados) } log.Println("[SEED] Seed de Administración completado.") @@ -812,11 +829,7 @@ func SeedSaas() { return } for _, rol := range roles { - if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil { - log.Printf("[SEED] Error asignando submódulos SaaS al rol '%s': %v", rol.Name, err) - } else { - log.Printf("[SEED] Submódulos SaaS asignados al rol '%s'", rol.Name) - } + asignarSubmodulosSiFaltan(&rol, insertados) } log.Println("[SEED] Seed de SaaS completado.") @@ -888,7 +901,7 @@ func SeedTelegram() { return } for _, rol := range roles { - db.Model(&rol).Association("Submodules").Append(&sub) + asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub}) } log.Println("[SEED] Seed de Telegram completado.") } @@ -946,7 +959,7 @@ func SeedPortalClientes() { return } for _, rol := range roles { - db.Model(&rol).Association("Submodules").Append(&insertados) + asignarSubmodulosSiFaltan(&rol, insertados) } log.Println("[SEED] Seed de Portal de Clientes completado.") } @@ -1030,7 +1043,7 @@ func SeedPartnerRecursos() { if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil { log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err) } else { - db.Model(&rolAdmin).Association("Submodules").Append(&sub) + asignarSubmodulosSiFaltan(&rolAdmin, []models.Submodules{sub}) log.Printf("[SEED] Submódulo 'Partner Recursos' asignado al rol 'Administrador'") } log.Println("[SEED] Seed de Partner Recursos completado.") @@ -1084,7 +1097,7 @@ func SeedContabilidadMenu() { if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil { log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err) } else { - db.Model(&rolAdmin).Association("Submodules").Append(&insertados) + asignarSubmodulosSiFaltan(&rolAdmin, insertados) log.Printf("[SEED] Submódulos de Contabilidad asignados al rol 'Administrador'") } log.Println("[SEED] Seed de Contabilidad completado.") diff --git a/pkg/models/contrato.go b/pkg/models/contrato.go index 8dcaa51..044e519 100644 --- a/pkg/models/contrato.go +++ b/pkg/models/contrato.go @@ -236,14 +236,42 @@ func DeleteContrato(id uint) error { return db.Delete(&c).Error } -// GuardarEnlacePago persiste el enlace de pago Bold en el contrato. +// GuardarEnlacePago persiste el enlace de cobro de un ciclo en el contrato. +// +// Generar un enlace nuevo significa que empieza un ciclo de cobro nuevo, así que +// también se reinicia pago_confirmado. Sin esto el flag quedaba en true para +// siempre desde el primer pago: en la segunda renovación el cliente pagaba, pero +// GetContratosConEnlacePendiente lo excluía del polling y MarcarContratoPagado +// salía por "ya estaba pagado" sin extender la fecha de vencimiento. func GuardarEnlacePago(contratoID uint, linkID, url string) error { return app.Http.Database.DB.Model(&Contrato{}).Where("id = ?", contratoID).Updates(map[string]interface{}{ "enlace_pago": url, "enlace_pago_link_id": linkID, + "pago_confirmado": false, + "fecha_pago": nil, }).Error } +// MarcarContratosVencidos pasa a estado "vencido" los contratos activos sin +// auto-renovación cuya fecha de vencimiento quedó atrás hace más de los días de +// gracia indicados. +// +// Sin esto ningún contrato salía nunca de "activo", y el cron de vencidos les +// seguía mandando correo con enlace de pago todos los días indefinidamente. +func MarcarContratosVencidos(diasGracia int) (int64, error) { + limite := time.Now().AddDate(0, 0, -diasGracia) + result := app.Http.Database.DB.Model(&Contrato{}). + Where("estado = ? AND fecha_vencimiento < ? AND pago_confirmado = ?", "activo", limite, false). + Update("estado", "vencido") + if result.Error != nil { + return 0, result.Error + } + if result.RowsAffected > 0 { + log.Printf("[CONTRATOS] %d contrato(s) pasaron a estado 'vencido' tras %d días sin pago", result.RowsAffected, diasGracia) + } + return result.RowsAffected, nil +} + // LimpiarEnlacePago borra el enlace vigente cuando se registra un pago aprobado, // para que la próxima notificación genere un link nuevo. func LimpiarEnlacePago(contratoID uint) error { diff --git a/pkg/services/cron_service.go b/pkg/services/cron_service.go index c5af18a..d04e0de 100644 --- a/pkg/services/cron_service.go +++ b/pkg/services/cron_service.go @@ -109,10 +109,20 @@ func ProcesarVencimientosProximos() { log.Println("[CRON] Vencimientos próximos finalizado") } +// diasGraciaVencimiento es cuántos días se sigue insistiendo a un contrato ya +// vencido antes de darlo por perdido y dejar de notificar. +const diasGraciaVencimiento = 30 + // ProcesarYaVencidos procesa reglas de tipo "ya_vencido" func ProcesarYaVencidos() { log.Println("[CRON] Procesando contratos ya vencidos...") + // Cerrar primero los que llevan demasiado tiempo vencidos sin pagar, para que + // dejen de recibir el correo diario de forma indefinida. + if _, err := models.MarcarContratosVencidos(diasGraciaVencimiento); err != nil { + log.Printf("[CRON] Error marcando contratos vencidos: %v", err) + } + reglas, err := models.GetReglasByTipoEvento("ya_vencido") if err != nil { log.Printf("[CRON] Error obteniendo reglas ya_vencido: %v", err) @@ -280,8 +290,12 @@ func VerificarSaludServidores() { // 2. Recursos (CPU / RAM / Disco) al límite if srv.MetricasJson != "" { var m struct { - CPU struct{ Porcentaje float64 `json:"porcentaje"` } `json:"cpu"` - RAM struct{ Porcentaje float64 `json:"porcentaje"` } `json:"ram"` + CPU struct { + Porcentaje float64 `json:"porcentaje"` + } `json:"cpu"` + RAM struct { + Porcentaje float64 `json:"porcentaje"` + } `json:"ram"` Discos []struct { Porcentaje float64 `json:"porcentaje"` Ruta string `json:"ruta"` diff --git a/pkg/services/dlocal_service.go b/pkg/services/dlocal_service.go index 0594970..fb70aed 100644 --- a/pkg/services/dlocal_service.go +++ b/pkg/services/dlocal_service.go @@ -83,13 +83,17 @@ type PlanUpdated struct { } type PagoRequest struct { - Currency string `json:"currency"` - Amount float64 `json:"amount"` - OrderID int `json:"order_id"` - Description string `json:"description"` - SuccessURL string `json:"success_url"` - BackURL string `json:"back_url"` - NotificationURL string `json:"notification_url"` + Currency string `json:"currency"` + Amount float64 `json:"amount"` + // OrderID es la referencia con la que después se reconcilia el pago. + // Debe ir en formato "contrato-{id}": es lo que espera el webhook + // (dlocal_controller.go) y la verificación por API para vincular el pago con + // su contrato. Antes se enviaba el ID numérico y el pago nunca se asociaba. + OrderID string `json:"order_id"` + Description string `json:"description"` + SuccessURL string `json:"success_url"` + BackURL string `json:"back_url"` + NotificationURL string `json:"notification_url"` } // Llama al endpoint /v1/me de DLocal usando la configuración @@ -117,7 +121,7 @@ func GetDLocalMe(cfg dlocalConfig) ([]byte, error) { req.Header.Set("Authorization", authToken) // Ejecutar la solicitud - client := &http.Client{} + client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err diff --git a/pkg/services/factura_attachment_service.go b/pkg/services/factura_attachment_service.go index 114a0e1..431b8b5 100644 --- a/pkg/services/factura_attachment_service.go +++ b/pkg/services/factura_attachment_service.go @@ -22,6 +22,9 @@ type TelegramAttachment struct { StagedAt time.Time } +// adjuntoHTTPClient limita cuánto se espera al descargar un archivo de Telegram. +var adjuntoHTTPClient = &http.Client{Timeout: 60 * time.Second} + const telegramAttachmentTTL = 15 * time.Minute var ( @@ -33,7 +36,7 @@ var ( // la API getFile + el endpoint de descarga, y lo deja en un directorio temporal. func DescargarDocumentoTelegram(botToken, fileID, originalName, mimeType string) (string, error) { getFileURL := fmt.Sprintf("https://api.telegram.org/bot%s/getFile?file_id=%s", botToken, fileID) - resp, err := http.Get(getFileURL) //nolint:noctx + resp, err := adjuntoHTTPClient.Get(getFileURL) //nolint:noctx if err != nil { return "", fmt.Errorf("no se pudo consultar el archivo en Telegram: %w", err) } @@ -50,7 +53,7 @@ func DescargarDocumentoTelegram(botToken, fileID, originalName, mimeType string) } downloadURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, result.Result.FilePath) - fresp, err := http.Get(downloadURL) //nolint:noctx + fresp, err := adjuntoHTTPClient.Get(downloadURL) //nolint:noctx if err != nil { return "", fmt.Errorf("no se pudo descargar el archivo: %w", err) } diff --git a/pkg/services/paypal_service.go b/pkg/services/paypal_service.go new file mode 100644 index 0000000..d9b60bf --- /dev/null +++ b/pkg/services/paypal_service.go @@ -0,0 +1,353 @@ +package services + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// ─── Endpoints de PayPal ────────────────────────────────────────────────────── + +const ( + paypalBaseSandbox = "https://api-m.sandbox.paypal.com" + paypalBaseLive = "https://api-m.paypal.com" +) + +// paypalCredenciales devuelve la URL base y las credenciales según el modo activo. +func paypalCredenciales(cfg *models.PaypalConfig) (baseURL, clientID, clientSecret string) { + if strings.EqualFold(cfg.Modo, "live") { + return paypalBaseLive, cfg.ClientIDProd, cfg.ClientSecretProd + } + return paypalBaseSandbox, cfg.ClientIDSandbox, cfg.ClientSecretSandbox +} + +var paypalHTTPClient = &http.Client{Timeout: 30 * time.Second} + +// ─── Autenticación ──────────────────────────────────────────────────────────── + +type paypalTokenResp struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` +} + +// PaypalAccessToken pide un token OAuth2 con las credenciales de la config activa. +func PaypalAccessToken(cfg *models.PaypalConfig) (string, error) { + baseURL, clientID, clientSecret := paypalCredenciales(cfg) + if clientID == "" || clientSecret == "" { + return "", fmt.Errorf("PayPal: faltan credenciales para el modo '%s'", cfg.Modo) + } + + form := url.Values{} + form.Set("grant_type", "client_credentials") + + req, err := http.NewRequest("POST", baseURL+"/v1/oauth2/token", strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.SetBasicAuth(clientID, clientSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := paypalHTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("PayPal: no se pudo conectar: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) + + var out paypalTokenResp + if err := json.Unmarshal(raw, &out); err != nil { + return "", fmt.Errorf("PayPal: respuesta de token inesperada (%d)", resp.StatusCode) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 || out.AccessToken == "" { + detalle := out.ErrorDesc + if detalle == "" { + detalle = out.Error + } + if detalle == "" { + detalle = string(raw) + } + return "", fmt.Errorf("PayPal: autenticación rechazada (%d): %s", resp.StatusCode, detalle) + } + return out.AccessToken, nil +} + +// paypalRequest hace una llamada autenticada a la API de PayPal. +func paypalRequest(cfg *models.PaypalConfig, token, method, endpoint string, body interface{}) ([]byte, int, error) { + baseURL, _, _ := paypalCredenciales(cfg) + + var reqBody io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, 0, err + } + reqBody = bytes.NewReader(b) + } + + req, err := http.NewRequest(method, baseURL+endpoint, reqBody) + if err != nil { + return nil, 0, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := paypalHTTPClient.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("PayPal: error de conexión: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) + return raw, resp.StatusCode, nil +} + +// ─── Creación de orden de pago ──────────────────────────────────────────────── + +type paypalOrderResp struct { + ID string `json:"id"` + Status string `json:"status"` + Links []struct { + Href string `json:"href"` + Rel string `json:"rel"` + } `json:"links"` + Message string `json:"message"` + Details []struct { + Issue string `json:"issue"` + Description string `json:"description"` + } `json:"details"` +} + +// PaypalCrearOrden crea una orden de cobro y devuelve (orderID, urlDeAprobacion). +// La referencia debe ir en formato "contrato-{id}": es lo que permite reconciliar +// después el pago con su contrato, igual que en Bold y dLocal. +func PaypalCrearOrden(cfg *models.PaypalConfig, referencia, descripcion, moneda string, monto float64) (string, string, error) { + token, err := PaypalAccessToken(cfg) + if err != nil { + return "", "", err + } + + returnURL := cfg.ReturnURL + if returnURL == "" { + returnURL = absAppURL("/pago-exitoso?ref=" + referencia) + } + cancelURL := cfg.CancelURL + if cancelURL == "" { + cancelURL = absAppURL("/pago-cancelado?ref=" + referencia) + } + + orden := map[string]interface{}{ + "intent": "CAPTURE", + "purchase_units": []map[string]interface{}{{ + "reference_id": referencia, + "custom_id": referencia, + "description": truncarPaypal(descripcion, 127), + "amount": map[string]string{ + "currency_code": moneda, + "value": fmt.Sprintf("%.2f", monto), + }, + }}, + "application_context": map[string]string{ + "return_url": returnURL, + "cancel_url": cancelURL, + "shipping_preference": "NO_SHIPPING", + "user_action": "PAY_NOW", + }, + } + + raw, status, err := paypalRequest(cfg, token, "POST", "/v2/checkout/orders", orden) + if err != nil { + return "", "", err + } + + var out paypalOrderResp + if err := json.Unmarshal(raw, &out); err != nil { + return "", "", fmt.Errorf("PayPal: respuesta inesperada al crear la orden (%d)", status) + } + if status < 200 || status >= 300 || out.ID == "" { + return "", "", fmt.Errorf("PayPal rechazó la orden (%d): %s", status, detallePaypal(out)) + } + + for _, l := range out.Links { + if l.Rel == "approve" || l.Rel == "payer-action" { + return out.ID, l.Href, nil + } + } + return out.ID, "", fmt.Errorf("PayPal creó la orden %s pero no devolvió enlace de aprobación", out.ID) +} + +// PaypalCapturarOrden cobra una orden ya aprobada por el cliente. +// Devuelve true si quedó capturada (o ya lo estaba). +func PaypalCapturarOrden(cfg *models.PaypalConfig, orderID string) (bool, error) { + token, err := PaypalAccessToken(cfg) + if err != nil { + return false, err + } + + raw, status, err := paypalRequest(cfg, token, "POST", "/v2/checkout/orders/"+orderID+"/capture", map[string]interface{}{}) + if err != nil { + return false, err + } + + var out paypalOrderResp + _ = json.Unmarshal(raw, &out) + + if out.Status == "COMPLETED" { + return true, nil + } + // PayPal responde 422 ORDER_ALREADY_CAPTURED si ya se cobró: es éxito, no error. + for _, d := range out.Details { + if d.Issue == "ORDER_ALREADY_CAPTURED" { + return true, nil + } + } + if status < 200 || status >= 300 { + return false, fmt.Errorf("PayPal no pudo capturar la orden (%d): %s", status, detallePaypal(out)) + } + return false, nil +} + +// PaypalConsultarOrden devuelve el estado actual de una orden. +func PaypalConsultarOrden(cfg *models.PaypalConfig, orderID string) (string, error) { + token, err := PaypalAccessToken(cfg) + if err != nil { + return "", err + } + raw, status, err := paypalRequest(cfg, token, "GET", "/v2/checkout/orders/"+orderID, nil) + if err != nil { + return "", err + } + var out paypalOrderResp + if err := json.Unmarshal(raw, &out); err != nil { + return "", fmt.Errorf("PayPal: respuesta inesperada (%d)", status) + } + if status < 200 || status >= 300 { + return "", fmt.Errorf("PayPal (%d): %s", status, detallePaypal(out)) + } + return out.Status, nil +} + +// ─── Verificación de webhook ────────────────────────────────────────────────── + +// PaypalVerificarWebhook comprueba contra PayPal que la notificación es auténtica. +// Sin esto cualquiera podría enviar un "pago completado" falso. +func PaypalVerificarWebhook(cfg *models.PaypalConfig, headers map[string]string, rawBody []byte) (bool, error) { + if cfg.WebhookID == "" { + return false, fmt.Errorf("PayPal: falta configurar el Webhook ID para poder verificar las notificaciones") + } + token, err := PaypalAccessToken(cfg) + if err != nil { + return false, err + } + + var evento json.RawMessage = rawBody + payload := map[string]interface{}{ + "auth_algo": headers["paypal-auth-algo"], + "cert_url": headers["paypal-cert-url"], + "transmission_id": headers["paypal-transmission-id"], + "transmission_sig": headers["paypal-transmission-sig"], + "transmission_time": headers["paypal-transmission-time"], + "webhook_id": cfg.WebhookID, + "webhook_event": evento, + } + + raw, status, err := paypalRequest(cfg, token, "POST", "/v1/notifications/verify-webhook-signature", payload) + if err != nil { + return false, err + } + var out struct { + VerificationStatus string `json:"verification_status"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return false, fmt.Errorf("PayPal: respuesta inesperada al verificar la firma (%d)", status) + } + return out.VerificationStatus == "SUCCESS", nil +} + +// PaypalEvento es la parte de la notificación que interesa para reconciliar. +type PaypalEvento struct { + ID string `json:"id"` + EventType string `json:"event_type"` + ResourceID string + Referencia string +} + +// ParsePaypalWebhook extrae el tipo de evento y la referencia del contrato. +func ParsePaypalWebhook(rawBody []byte) (*PaypalEvento, error) { + var body struct { + ID string `json:"id"` + EventType string `json:"event_type"` + Resource struct { + ID string `json:"id"` + CustomID string `json:"custom_id"` + PurchaseUnits []struct { + ReferenceID string `json:"reference_id"` + CustomID string `json:"custom_id"` + } `json:"purchase_units"` + SupplementaryData struct { + RelatedIDs struct { + OrderID string `json:"order_id"` + } `json:"related_ids"` + } `json:"supplementary_data"` + } `json:"resource"` + } + if err := json.Unmarshal(rawBody, &body); err != nil { + return nil, fmt.Errorf("PayPal: no se pudo interpretar la notificación: %w", err) + } + + ev := &PaypalEvento{ID: body.ID, EventType: body.EventType, ResourceID: body.Resource.ID} + + // La referencia puede venir en custom_id (capturas) o en purchase_units (órdenes). + ev.Referencia = body.Resource.CustomID + if ev.Referencia == "" { + for _, pu := range body.Resource.PurchaseUnits { + if pu.CustomID != "" { + ev.Referencia = pu.CustomID + break + } + if pu.ReferenceID != "" { + ev.Referencia = pu.ReferenceID + break + } + } + } + // En PAYMENT.CAPTURE.* el ID del recurso es la captura, no la orden. + if body.Resource.SupplementaryData.RelatedIDs.OrderID != "" { + ev.ResourceID = body.Resource.SupplementaryData.RelatedIDs.OrderID + } + return ev, nil +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +func detallePaypal(out paypalOrderResp) string { + if len(out.Details) > 0 { + d := out.Details[0] + if d.Description != "" { + return d.Issue + ": " + d.Description + } + return d.Issue + } + if out.Message != "" { + return out.Message + } + return "sin detalle" +} + +func truncarPaypal(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] +} diff --git a/pkg/services/renovacion_service.go b/pkg/services/renovacion_service.go index dd79a1f..e3494c7 100644 --- a/pkg/services/renovacion_service.go +++ b/pkg/services/renovacion_service.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "log" + "math" + "strings" "text/template" "time" @@ -328,11 +330,49 @@ func ObtenerOCrearEnlacePago(contrato *models.Contrato, gateway string) string { switch gateway { case "dlocal": return obtenerEnlaceDlocal(contrato) + case "paypal": + return obtenerEnlacePaypal(contrato) default: // "bold" return obtenerEnlaceBold(contrato) } } +// obtenerEnlacePaypal crea una orden de pago en PayPal y persiste el enlace. +func obtenerEnlacePaypal(contrato *models.Contrato) string { + cfg, err := models.GetPaypalConfig() + if err != nil { + log.Printf("[PAYPAL] sin config activa para contrato %d", contrato.ID) + return "" + } + + desc := fmt.Sprintf("Renovación contrato #%d", contrato.ID) + if len(contrato.Servicios) > 0 { + desc = fmt.Sprintf("Renovación: %s", contrato.Servicios[0].Nombre) + } + + referencia := fmt.Sprintf("contrato-%d", contrato.ID) + orderID, urlAprobacion, err := PaypalCrearOrden(cfg, referencia, desc, monedaDeContrato(contrato), contrato.PrecioAcordado) + if err != nil { + log.Printf("[PAYPAL] error creando orden para contrato %d: %v", contrato.ID, err) + return "" + } + + _ = models.GuardarEnlacePago(contrato.ID, orderID, urlAprobacion) + contrato.EnlacePago = urlAprobacion + contrato.EnlacePagoLinkID = orderID + return urlAprobacion +} + +// monedaDeContrato devuelve la moneda del contrato, con COP como valor por +// defecto. Antes la moneda iba fija a "COP" en ambas pasarelas: un contrato +// pactado en USD generaba un cobro por esa cifra en pesos (500 USD → 500 COP). +func monedaDeContrato(contrato *models.Contrato) string { + if m := strings.ToUpper(strings.TrimSpace(contrato.Moneda)); m != "" { + return m + } + return "COP" +} + // obtenerEnlaceBold crea un payment link en Bold y lo persiste en el contrato. func obtenerEnlaceBold(contrato *models.Contrato) string { cfg, err := models.GetBoldConfig() @@ -349,8 +389,8 @@ func obtenerEnlaceBold(contrato *models.Contrato) string { req := BoldPaymentLinkRequest{ AmountType: "CLOSE", Amount: BoldAmountField{ - Currency: "COP", - TotalAmount: int64(contrato.PrecioAcordado), + Currency: monedaDeContrato(contrato), + TotalAmount: int64(math.Round(contrato.PrecioAcordado)), }, Description: desc, Reference: fmt.Sprintf("contrato-%d", contrato.ID), @@ -382,13 +422,18 @@ func obtenerEnlaceDlocal(contrato *models.Contrato) string { desc = fmt.Sprintf("Renovación: %s", contrato.Servicios[0].Nombre) } + referencia := fmt.Sprintf("contrato-%d", contrato.ID) pagoReq := PagoRequest{ - Currency: "COP", + Currency: monedaDeContrato(contrato), Amount: contrato.PrecioAcordado, - OrderID: int(contrato.ID), + OrderID: referencia, Description: desc, - SuccessURL: cfg.UrlProd + "/pago-exitoso", - BackURL: cfg.UrlProd + "/pago-cancelado", + // Las URLs de retorno y notificación son las del panel, no las de la API + // de dLocal: antes apuntaban a cfg.UrlProd y el cliente terminaba en una + // página inexistente. Sin notification_url, dLocal nunca llamaba al webhook. + SuccessURL: absAppURL("/pago-exitoso?ref=" + referencia), + BackURL: absAppURL("/pago-cancelado?ref=" + referencia), + NotificationURL: absAppURL("/webhooks/dlocal"), } body, err := CreatePago(*cfg, pagoReq) diff --git a/pkg/services/telegram_agent_service.go b/pkg/services/telegram_agent_service.go index 5e38490..6c68843 100644 --- a/pkg/services/telegram_agent_service.go +++ b/pkg/services/telegram_agent_service.go @@ -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 diff --git a/pkg/services/telegram_service.go b/pkg/services/telegram_service.go index fa164e1..d735868 100755 --- a/pkg/services/telegram_service.go +++ b/pkg/services/telegram_service.go @@ -6,8 +6,13 @@ import ( "fmt" "net/http" "os" + "time" ) +// telegramHTTPClient evita que una llamada colgada a Telegram deje la +// goroutine bloqueada indefinidamente (http.Get/Post no tienen timeout). +var telegramHTTPClient = &http.Client{Timeout: 20 * time.Second} + type TelegramService struct { BotToken string // exportado para uso desde otros paquetes } @@ -44,7 +49,7 @@ func (ts *TelegramService) SendMessage(chatID interface{}, message string) error return fmt.Errorf("error marshalling request body: %v", err) } - resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonData)) + resp, err := telegramHTTPClient.Post(apiURL, "application/json", bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("error sending request to Telegram API: %v", err) } @@ -62,7 +67,7 @@ func GetBotUsername(botToken string) string { if botToken == "" { return "" } - resp, err := http.Get(fmt.Sprintf("https://api.telegram.org/bot%s/getMe", botToken)) + resp, err := telegramHTTPClient.Get(fmt.Sprintf("https://api.telegram.org/bot%s/getMe", botToken)) if err != nil { return "" } @@ -100,7 +105,7 @@ func UpdatesRecientesDelBot(botToken string) ([]RemitenteReciente, error) { if botToken == "" { return nil, fmt.Errorf("bot token vacío") } - resp, err := http.Get(fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?limit=100", botToken)) //nolint:noctx + resp, err := telegramHTTPClient.Get(fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?limit=100", botToken)) //nolint:noctx if err != nil { return nil, fmt.Errorf("no se pudo consultar Telegram: %w", err) } diff --git a/resources/views/renovaciones/reglas.html b/resources/views/renovaciones/reglas.html index 36d54e4..5b1f15a 100644 --- a/resources/views/renovaciones/reglas.html +++ b/resources/views/renovaciones/reglas.html @@ -127,6 +127,7 @@
Genera automáticamente {{.EnlacePago}} en la plantilla