package controllers import ( "encoding/json" "fmt" "io" "log" "net/http" "net/url" "strconv" "strings" "time" "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" "github.com/sujit-baniya/fiber-boilerplate/pkg/services" ) // filterQueryParam elimina un parámetro específico del querystring. func filterQueryParam(qs, param string) string { vals, err := url.ParseQuery(qs) if err != nil { return qs } vals.Del(param) return vals.Encode() } // ─── helpers internos ──────────────────────────────────────────────────────── // coolifyDo ejecuta una petición a la API de Coolify y devuelve el body crudo. func coolifyDo(method, endpoint string, reqBody io.Reader, contentType string, cfg *models.CoolifyConfig) ([]byte, int, error) { base := strings.TrimRight(cfg.BaseURL, "/") url := fmt.Sprintf("%s/api/v1%s", base, endpoint) client := &http.Client{Timeout: 30 * time.Second} req, err := http.NewRequest(method, url, reqBody) if err != nil { return nil, 0, err } req.Header.Set("Authorization", "Bearer "+cfg.ApiToken) req.Header.Set("Accept", "application/json") if contentType != "" { req.Header.Set("Content-Type", contentType) } resp, err := client.Do(req) if err != nil { return nil, 0, err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) return body, resp.StatusCode, nil } // coolifyResolveConfig retorna la config a usar según ?config_id= o la activa por defecto. func coolifyResolveConfig(c *fiber.Ctx) (*models.CoolifyConfig, error) { if idStr := c.Query("config_id"); idStr != "" { id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { return nil, fmt.Errorf("config_id inválido") } return models.GetCoolifyConfigByID(uint(id)) } return models.GetCoolifyConfig() } // coolifyProxy resuelve config, aplica qs de la request y responde al frontend. func coolifyProxy(c *fiber.Ctx, method, endpoint string) error { cfg, err := coolifyResolveConfig(c) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Configura primero la integración Coolify"}) } if !cfg.Activo { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "La integración Coolify está inactiva"}) } // Filtrar config_id del querystring para no pasarlo a Coolify qs := string(c.Request().URI().QueryString()) if qs != "" { filtered := filterQueryParam(qs, "config_id") if filtered != "" { qs = filtered } else { qs = "" } } ep := endpoint if qs != "" { ep = endpoint + "?" + qs } var bodyReader io.Reader ct := "" if raw := c.Body(); len(raw) > 0 { bodyReader = strings.NewReader(string(raw)) ct = c.Get("Content-Type", "application/json") } body, status, err := coolifyDo(method, ep, bodyReader, ct, cfg) if err != nil { return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()}) } var result json.RawMessage if err := json.Unmarshal(body, &result); err != nil { result = json.RawMessage(fmt.Sprintf(`{"raw":%q}`, string(body))) } c.Status(status) return c.JSON(result) } // ─── Página principal ───────────────────────────────────────────────────────── func CoolifyIndex(c *fiber.Ctx) error { cfg, _ := models.GetCoolifyConfig() return c.Render("coolify", fiber.Map{ "user": c.Locals("user").(map[string]interface{}), "modules": c.Locals("userModules"), "config": cfg, }, "layouts/main") } // ─── Configuración ──────────────────────────────────────────────────────────── func CoolifyGetConfig(c *fiber.Ctx) error { cfg, err := models.GetCoolifyConfig() if err != nil { return c.JSON(fiber.Map{"config": nil}) } masked := "••••••••" if cfg.ApiToken == "" { masked = "" } return c.JSON(fiber.Map{"config": fiber.Map{ "ID": cfg.ID, "nombre": cfg.Nombre, "base_url": cfg.BaseURL, "api_token": masked, "activo": cfg.Activo, }}) } func CoolifySaveConfig(c *fiber.Ctx) error { type Req struct { Nombre string `json:"nombre"` BaseURL string `json:"base_url"` ApiToken string `json:"api_token"` Activo bool `json:"activo"` } var req Req if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) } req.BaseURL = strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") if req.BaseURL == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "base_url es requerido"}) } cfg, err := models.UpsertCoolifyConfig(req.Nombre, req.BaseURL, req.ApiToken, req.Activo) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"ok": true, "config": cfg}) } // CoolifyTestConnection verifica conectividad con /health (no requiere auth). func CoolifyTestConnection(c *fiber.Ctx) error { cfg, err := models.GetCoolifyConfig() if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Sin config"}) } base := strings.TrimRight(cfg.BaseURL, "/") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Get(base + "/api/health") if err != nil { return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()}) } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) var result json.RawMessage if jsonErr := json.Unmarshal(body, &result); jsonErr != nil { result = json.RawMessage(fmt.Sprintf(`{"raw":%q}`, string(body))) } c.Status(resp.StatusCode) return c.JSON(result) } // ─── Applications ───────────────────────────────────────────────────────────── func CoolifyListApplications(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/applications") } func CoolifyGetApplication(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid")) } func CoolifyApplicationLogs(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid")+"/logs") } func CoolifyApplicationEnvs(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid")+"/envs") } func CoolifyApplicationEnvCreate(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodPost, "/applications/"+c.Params("uuid")+"/envs") } func CoolifyApplicationEnvUpdateBulk(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodPatch, "/applications/"+c.Params("uuid")+"/envs") } func CoolifyApplicationEnvDelete(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodDelete, "/applications/"+c.Params("uuid")+"/envs/"+c.Params("env_id")) } func CoolifyApplicationStart(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid")+"/start") } func CoolifyApplicationStop(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid")+"/stop") } func CoolifyApplicationRestart(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid")+"/restart") } func CoolifyApplicationDeployments(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid")+"/deployments") } func CoolifyApplicationDeploy(c *fiber.Ctx) error { // POST /api/v1/deploy?uuid={uuid}&force=false return coolifyProxy(c, http.MethodPost, "/deploy") } // ─── Servers ────────────────────────────────────────────────────────────────── func CoolifyListServers(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/servers") } func CoolifyGetServer(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/servers/"+c.Params("uuid")) } func CoolifyServerResources(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/servers/"+c.Params("uuid")+"/resources") } func CoolifyServerDomains(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/servers/"+c.Params("uuid")+"/domains") } func CoolifyServerValidate(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/servers/"+c.Params("uuid")+"/validate") } // ─── Services ───────────────────────────────────────────────────────────────── func CoolifyListServices(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/services") } func CoolifyGetService(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/services/"+c.Params("uuid")) } func CoolifyServiceStart(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/services/"+c.Params("uuid")+"/start") } func CoolifyServiceStop(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/services/"+c.Params("uuid")+"/stop") } func CoolifyServiceRestart(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/services/"+c.Params("uuid")+"/restart") } func CoolifyServiceEnvs(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/services/"+c.Params("uuid")+"/envs") } func CoolifyServiceEnvUpdateBulk(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodPatch, "/services/"+c.Params("uuid")+"/envs") } // ─── Databases ──────────────────────────────────────────────────────────────── func CoolifyListDatabases(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/databases") } func CoolifyGetDatabase(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/databases/"+c.Params("uuid")) } func CoolifyDatabaseStart(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/databases/"+c.Params("uuid")+"/start") } func CoolifyDatabaseStop(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/databases/"+c.Params("uuid")+"/stop") } func CoolifyDatabaseRestart(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/databases/"+c.Params("uuid")+"/restart") } // ─── Projects ───────────────────────────────────────────────────────────────── func CoolifyListProjects(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/projects") } func CoolifyGetProject(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/projects/"+c.Params("uuid")) } func CoolifyProjectEnvironments(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/projects/"+c.Params("uuid")+"/environments") } // ─── Deployments ────────────────────────────────────────────────────────────── func CoolifyListDeployments(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/deployments") } func CoolifyGetDeployment(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/deployments/"+c.Params("uuid")) } // ─── Teams ──────────────────────────────────────────────────────────────────── func CoolifyListTeams(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/teams") } func CoolifyCurrentTeam(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/teams/current") } func CoolifyTeamMembers(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/teams/current/members") } // CoolifyWebhook recibe notificaciones push de Coolify y las reenvía a Telegram. // Configurar en Coolify → Settings → Notifications → Add Notification → Webhook // URL: https://admin.u-site.app/webhooks/coolify/:config_id func CoolifyWebhook(c *fiber.Ctx) error { body := c.Body() log.Printf("[COOLIFY_WEBHOOK] config_id=%s payload=%s", c.Params("config_id"), string(body)) if len(body) == 0 { return c.SendStatus(fiber.StatusOK) } // Identificar la instancia Coolify si viene en la ruta var instanceName string if idStr := c.Params("config_id"); idStr != "" { if id, err := strconv.ParseUint(idStr, 10, 32); err == nil { if cfg, err := models.GetCoolifyConfigByID(uint(id)); err == nil { instanceName = cfg.Nombre } } } go sendCoolifyTelegramNotif(body, instanceName) return c.SendStatus(fiber.StatusOK) } // sendCoolifyTelegramNotif parsea el payload de Coolify y lo envía a Telegram. func sendCoolifyTelegramNotif(body []byte, instanceName string) { // Intentar parsear como JSON var payload map[string]interface{} isJSON := json.Unmarshal(body, &payload) == nil // Extraer campos con múltiples nombres posibles (Coolify cambia formato según versión) get := func(keys ...string) string { if !isJSON { return "" } for _, k := range keys { if v, ok := payload[k]; ok { if s, ok := v.(string); ok && s != "" { return s } } } return "" } appName := get("application_name", "name", "app_name", "resource_name") appUUID := get("application_uuid", "uuid", "resource_uuid") status := get("status", "type", "event", "deployment_status") message := get("message", "description", "text", "body") fqdn := get("fqdn", "url", "application_url", "application_fqdn") serverName := get("server_name", "server") // Si no pudimos parsear como JSON, tratar el body entero como mensaje if !isJSON { message = strings.TrimSpace(string(body)) if len(message) > 500 { message = message[:500] + "…" } } // Emoji según estado emoji := "🔔" statusLower := strings.ToLower(status + " " + message) switch { case strings.Contains(statusLower, "success") || strings.Contains(statusLower, "exitoso") || strings.Contains(statusLower, "finished"): emoji = "✅" case strings.Contains(statusLower, "fail") || strings.Contains(statusLower, "error") || strings.Contains(statusLower, "fallo"): emoji = "❌" case strings.Contains(statusLower, "running") || strings.Contains(statusLower, "deploying") || strings.Contains(statusLower, "start") || strings.Contains(statusLower, "building"): emoji = "🚀" case strings.Contains(statusLower, "stop") || strings.Contains(statusLower, "detenido"): emoji = "⏹" case strings.Contains(statusLower, "restart") || strings.Contains(statusLower, "reinici"): emoji = "🔄" } // Construir el mensaje lines := []string{fmt.Sprintf("%s Coolify", emoji)} if instanceName != "" { lines = append(lines, fmt.Sprintf("Instancia: %s", instanceName)) } if appName != "" { lines = append(lines, fmt.Sprintf("App: %s", appName)) } if appUUID != "" && appUUID != appName { lines = append(lines, fmt.Sprintf("UUID: %s", appUUID)) } if serverName != "" { lines = append(lines, fmt.Sprintf("Servidor: %s", serverName)) } if status != "" { lines = append(lines, fmt.Sprintf("Estado: %s", status)) } if message != "" && message != status { if len(message) > 300 { message = message[:300] + "…" } lines = append(lines, fmt.Sprintf("ℹ️ %s", message)) } if fqdn != "" { lines = append(lines, fmt.Sprintf("🌐 %s", fqdn)) } text := strings.Join(lines, "\n") // Obtener el bot del agente (TelegramConfig ID que tenga agent bot activo) tgCfg, err := models.GetAgentTelegramConfig() if err != nil { log.Printf("[COOLIFY_WEBHOOK] Sin config Telegram para notificar: %v", err) return } // Enviar a todos los chats autorizados del agente auths, _ := models.GetAllAgentAuth() if len(auths) == 0 { log.Printf("[COOLIFY_WEBHOOK] Sin chats autorizados para notificar") return } svc := &services.TelegramService{BotToken: tgCfg.BotToken} for _, auth := range auths { if !auth.Activo { continue } if err := svc.SendMessage(auth.ChatID, text); err != nil { log.Printf("[COOLIFY_WEBHOOK] Error enviando a chat %d: %v", auth.ChatID, err) } } } // ─── CRUD de instancias Coolify ─────────────────────────────────────────────── func CoolifyListConfigs(c *fiber.Ctx) error { items, err := models.GetAllCoolifyConfigs() if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } // Ocultar tokens type safe struct { ID uint `json:"id"` Nombre string `json:"nombre"` BaseURL string `json:"base_url"` Activo bool `json:"activo"` } out := make([]safe, len(items)) for i, cfg := range items { out[i] = safe{ID: cfg.ID, Nombre: cfg.Nombre, BaseURL: cfg.BaseURL, Activo: cfg.Activo} } return c.JSON(fiber.Map{"items": out}) } func CoolifyCreateConfig(c *fiber.Ctx) error { type Req struct { Nombre string `json:"nombre"` BaseURL string `json:"base_url"` ApiToken string `json:"api_token"` Activo bool `json:"activo"` } var req Req if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "body inválido"}) } req.BaseURL = strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") if req.BaseURL == "" || req.ApiToken == "" { return c.Status(400).JSON(fiber.Map{"error": "base_url y api_token son requeridos"}) } cfg := &models.CoolifyConfig{Nombre: req.Nombre, BaseURL: req.BaseURL, ApiToken: req.ApiToken, Activo: req.Activo} if err := models.CreateCoolifyConfig(cfg); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.Status(201).JSON(fiber.Map{"ok": true, "id": cfg.ID}) } func CoolifyUpdateConfig(c *fiber.Ctx) error { id, err := strconv.ParseUint(c.Params("id"), 10, 32) if err != nil { return c.Status(400).JSON(fiber.Map{"error": "id inválido"}) } type Req struct { Nombre string `json:"nombre"` BaseURL string `json:"base_url"` ApiToken string `json:"api_token"` Activo bool `json:"activo"` } var req Req if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "body inválido"}) } req.BaseURL = strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") cfg, err := models.UpdateCoolifyConfig(uint(id), req.Nombre, req.BaseURL, req.ApiToken, req.Activo) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"ok": true, "id": cfg.ID}) } func CoolifyDeleteConfig(c *fiber.Ctx) error { id, err := strconv.ParseUint(c.Params("id"), 10, 32) if err != nil { return c.Status(400).JSON(fiber.Map{"error": "id inválido"}) } if err := models.DeleteCoolifyConfig(uint(id)); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"ok": true}) } func CoolifyTestConfig(c *fiber.Ctx) error { id, err := strconv.ParseUint(c.Params("id"), 10, 32) if err != nil { return c.Status(400).JSON(fiber.Map{"error": "id inválido"}) } cfg, err := models.GetCoolifyConfigByID(uint(id)) if err != nil { return c.Status(404).JSON(fiber.Map{"error": "config no encontrada"}) } base := strings.TrimRight(cfg.BaseURL, "/") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Get(base + "/api/health") if err != nil { return c.Status(502).JSON(fiber.Map{"error": err.Error()}) } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) var result json.RawMessage if jsonErr := json.Unmarshal(body, &result); jsonErr != nil { result = json.RawMessage(fmt.Sprintf(`{"raw":%q}`, string(body))) } c.Status(resp.StatusCode) return c.JSON(result) }