package controllers import ( "fmt" "strings" "unicode" "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" "github.com/sujit-baniya/fiber-boilerplate/pkg/services" ) // sanitizeCFToken limpia el token antes de usarlo como Bearer en Cloudflare. // Cubre los casos más comunes de pegado incorrecto desde el dashboard: // - BOM UTF-8 // - Prefijo "Bearer " copiado accidentalmente // - Comillas simples/dobles alrededor del token // - Espacios y caracteres de control (tabulaciones, saltos de línea) // - Caracteres fuera del rango ASCII imprimible (U+0021–U+007E) func sanitizeCFToken(s string) string { // 1. BOM UTF-8 s = strings.TrimPrefix(s, "\xef\xbb\xbf") // 2. Espacios al inicio/fin (cubre paste con espacio visible) s = strings.TrimSpace(s) // 3. Prefijo "Bearer " copiado accidentalmente (case-insensitive) if after, ok := strings.CutPrefix(strings.ToLower(s), "bearer "); ok { s = s[len(s)-len(after):] // preservar casing original del token } s = strings.TrimSpace(s) // 4. Comillas envolventes (simples o dobles) if len(s) >= 2 { if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { s = s[1 : len(s)-1] } } // 5. Solo ASCII imprimible (U+0021–U+007E), descarta control chars y unicode var b strings.Builder for _, r := range s { if r > 32 && r < 127 && unicode.IsPrint(r) { b.WriteRune(r) } } return b.String() } // ─── Configuración ──────────────────────────────────────────────────────────── // CloudflareConfigPage renderiza la vista de gestión de Cloudflare. func CloudflareConfigPage(c *fiber.Ctx) error { cfg, _ := models.GetCloudflareConfig() if err := c.Render("cloudflare", fiber.Map{ "Title": "Cloudflare API", "Config": cfg, "user": c.Locals("user"), "modules": c.Locals("userModules"), }, "layouts/main"); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return nil } // SaveCloudflareConfig guarda o actualiza las credenciales de Cloudflare. func SaveCloudflareConfig(c *fiber.Ctx) error { type body struct { ID uint `json:"id" form:"id"` AuthType string `json:"auth_type" form:"auth_type"` // "token" o "global_key" APIToken string `json:"api_token" form:"api_token"` Email string `json:"email" form:"email"` AccountID string `json:"account_id" form:"account_id"` Nota string `json:"nota" form:"nota"` } var b body if err := c.BodyParser(&b); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) } if b.APIToken == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "api_token requerido"}) } if b.AuthType == "" { b.AuthType = "token" } if b.AuthType == "global_key" && b.Email == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email requerido para auth_type=global_key"}) } cfg := models.CloudflareConfig{ AuthType: b.AuthType, APIToken: sanitizeCFToken(b.APIToken), Email: strings.TrimSpace(b.Email), AccountID: sanitizeCFToken(b.AccountID), Nota: b.Nota, } cfg.ID = b.ID if err := models.SaveCloudflareConfig(cfg); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"message": "Configuración guardada"}) } // ─── Helper ─────────────────────────────────────────────────────────────────── // CleanCloudflareToken re-guarda el token activo aplicando saneamiento. // Útil cuando el token fue guardado antes del fix de caracteres invisibles. func CleanCloudflareToken(c *fiber.Ctx) error { cfg, err := models.GetCloudflareConfig() if err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Sin configuración activa"}) } before := len([]rune(cfg.APIToken)) cfg.APIToken = sanitizeCFToken(cfg.APIToken) cfg.AccountID = sanitizeCFToken(cfg.AccountID) cfg.Email = strings.TrimSpace(cfg.Email) after := len([]rune(cfg.APIToken)) if err := models.SaveCloudflareConfig(*cfg); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{ "message": "Token re-guardado limpio", "chars_before": before, "chars_after": after, "chars_removed": before - after, }) } func cloudflareClient() (*services.CloudflareClient, error) { cfg, err := models.GetCloudflareConfig() if err != nil { return nil, err } if cfg.AuthType == "global_key" { return services.NewCloudflareClientGlobalKey( strings.TrimSpace(cfg.Email), sanitizeCFToken(cfg.APIToken), sanitizeCFToken(cfg.AccountID), ), nil } return services.NewCloudflareClient(sanitizeCFToken(cfg.APIToken), sanitizeCFToken(cfg.AccountID)), nil } // VerifyCloudflareToken verifica las credenciales y devuelve sus permisos reales. func VerifyCloudflareToken(c *fiber.Ctx) error { cfg, err := models.GetCloudflareConfig() if err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "ok": false, "error": "No hay configuración activa de Cloudflare", }) } raw := cfg.APIToken token := sanitizeCFToken(raw) if token == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"ok": false, "error": "Token vacío"}) } dirty := raw != token runes := []rune(token) preview := token if len(runes) > 14 { preview = string(runes[:6]) + "…" + string(runes[len(runes)-6:]) } isGlobal := cfg.AuthType == "global_key" // Diagnóstico de formato según tipo var tokenDiag fiber.Map if isGlobal { hintFormat := "" if len(token) != 37 { hintFormat = fmt.Sprintf( "Longitud %d ≠ 37. La Global API Key tiene exactamente 37 caracteres hexadecimales.", len(token), ) } tokenDiag = fiber.Map{ "auth_type": "global_key", "length": len(token), "expected_length": "37 caracteres (Global API Key)", "preview": preview, "was_sanitized": dirty, "email": cfg.Email, "hint_format": hintFormat, } } else { hintFormat := "" if len(token) != 40 { hintFormat = fmt.Sprintf( "Longitud %d ≠ 40. Posible token incompleto, con prefijo extra, o tipo incorrecto (¿estás usando Global API Key en vez de API Token?)", len(token), ) } tokenDiag = fiber.Map{ "auth_type": "token", "length": len(token), "expected_length": "40 caracteres (API Token estándar)", "preview": preview, "was_sanitized": dirty, "raw_length": len([]rune(raw)), "hint_format": hintFormat, } } var client *services.CloudflareClient if isGlobal { client = services.NewCloudflareClientGlobalKey( strings.TrimSpace(cfg.Email), token, sanitizeCFToken(cfg.AccountID), ) } else { client = services.NewCloudflareClient(token, sanitizeCFToken(cfg.AccountID)) } // ── Para Global API Key: no existe /user/tokens/verify, verificamos con /user ── if isGlobal { user, err := client.GetUser() if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "ok": false, "step": "get_user", "error": err.Error(), "token": tokenDiag, "hint": "Verifica que el email y la Global API Key sean correctos. La Global API Key se encuentra en dash.cloudflare.com → Mi Perfil → API Tokens → Global API Key → View.", }) } zones, zonesErr := client.GetZones() zonesOK := zonesErr == nil zonesErrMsg := "" if zonesErr != nil { zonesErrMsg = zonesErr.Error() } dnsOK := false dnsErrMsg := "" dnsZoneID := "" if zonesOK && len(zones) > 0 { dnsZoneID = zones[0].ID _, dnsErr := client.GetDNSRecords(dnsZoneID) dnsOK = dnsErr == nil if dnsErr != nil { dnsErrMsg = dnsErr.Error() } } return c.JSON(fiber.Map{ "ok": true, "auth_type": "global_key", "user_email": user.Email, "user_id": user.ID, "token": tokenDiag, "permissions_test": fiber.Map{ "zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg}, "dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg, "zone_tested": dnsZoneID}, }, }) } // ── API Token normal ── verify, err := client.VerifyToken() if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "ok": false, "step": "verify_token", "error": err.Error(), "token": tokenDiag, "hint": "Ve a dash.cloudflare.com → Mi Perfil → API Tokens → crea un token nuevo con permisos Zone:Read y DNS:Read/Write", }) } // 2. Obtener políticas reales del token detail, detailErr := client.GetTokenDetail(verify.ID) detailErrMsg := "" if detailErr != nil { detailErrMsg = detailErr.Error() } // 3. Probar Zone:Zone:Read zones, zonesErr := client.GetZones() zonesOK := zonesErr == nil zonesErrMsg := "" if zonesErr != nil { zonesErrMsg = zonesErr.Error() } // 4. Probar Zone:DNS:Read en la primera zona dnsOK := false dnsErrMsg := "" dnsZoneID := "" if zonesOK && len(zones) > 0 { dnsZoneID = zones[0].ID _, dnsErr := client.GetDNSRecords(dnsZoneID) dnsOK = dnsErr == nil if dnsErr != nil { dnsErrMsg = dnsErr.Error() } } hint := "Token válido con todos los permisos necesarios" if !zonesOK { hint = "Falta permiso Zone:Zone:Read" } else if !dnsOK { hint = "Falta permiso Zone:DNS:Read — o el token tiene scope de zonas específicas y no incluye la zona consultada. En Cloudflare: edita el token → Zone Resources → cambia a 'All zones'." } return c.JSON(fiber.Map{ "ok": true, "token_status": verify.Status, "token_id": verify.ID, "token_len": len(token), "token_prev": preview, "was_dirty": dirty, "detail_error": detailErrMsg, "policies": func() interface{} { if detail != nil { return detail.Policies }; return nil }(), "permissions_test": fiber.Map{ "zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg}, "dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg, "zone_tested": dnsZoneID}, }, "hint": hint, }) } // ─── Endpoints de datos ─────────────────────────────────────────────────────── // GetCloudflareUser devuelve la información del usuario autenticado en Cloudflare. func GetCloudflareUser(c *fiber.Ctx) error { client, err := cloudflareClient() if err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "error": "No se encontró configuración activa de Cloudflare", }) } data, err := client.GetUser() if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"data": data}) } // GetCloudflareZones lista todas las zonas (dominios) de la cuenta. func GetCloudflareZones(c *fiber.Ctx) error { client, err := cloudflareClient() if err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "error": "No se encontró configuración activa de Cloudflare", }) } data, err := client.GetZones() if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"data": data}) } // GetCloudflareDNS devuelve los registros DNS de una zona (:zone_id). func GetCloudflareDNS(c *fiber.Ctx) error { zoneID := c.Params("zone_id") if zoneID == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id requerido"}) } client, err := cloudflareClient() if err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "error": "No se encontró configuración activa de Cloudflare", }) } data, err := client.GetDNSRecords(zoneID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"data": data, "zone_id": zoneID}) } // GetCloudflareSSL devuelve los certificados SSL de una zona (:zone_id). // Intenta primero certificate_packs?status=all; si falla (plan Free o sin ACM) // devuelve el estado de Universal SSL como fallback. func GetCloudflareSSL(c *fiber.Ctx) error { zoneID := c.Params("zone_id") if zoneID == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id requerido"}) } client, err := cloudflareClient() if err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "error": "No se encontró configuración activa de Cloudflare", }) } // 1. Intentar certificate_packs?status=all packs, packsErr := client.GetSSLCertificates(zoneID) if packsErr == nil { return c.JSON(fiber.Map{"data": packs, "zone_id": zoneID, "source": "certificate_packs"}) } // 2. Fallback: Universal SSL settings (disponible en todos los planes) universal, uErr := client.GetUniversalSSLSettings(zoneID) if uErr != nil { // Devolver ambos errores para diagnóstico return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": packsErr.Error(), "universal_error": uErr.Error(), "hint": "El endpoint ssl/certificate_packs requiere permiso 'SSL and Certificates:Read'. Si usas API Token, asegúrate de que tenga ese permiso.", }) } return c.JSON(fiber.Map{ "data": nil, "zone_id": zoneID, "source": "universal_ssl", "universal_ssl": fiber.Map{ "enabled": universal.Enabled, "note": "Plan actual no expone certificate_packs. Universal SSL: " + func() string { if universal.Enabled { return "ACTIVO" }; return "INACTIVO" }(), }, "packs_error": packsErr.Error(), }) } // GetCloudflareFirewall devuelve las reglas de firewall de una zona (:zone_id). func GetCloudflareFirewall(c *fiber.Ctx) error { zoneID := c.Params("zone_id") if zoneID == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id requerido"}) } client, err := cloudflareClient() if err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "error": "No se encontró configuración activa de Cloudflare", }) } data, err := client.GetFirewallRules(zoneID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"data": data, "zone_id": zoneID}) } // ─── DNS CRUD ───────────────────────────────────────────────────────────────── // CreateCloudflareDNS crea un nuevo registro DNS en la zona (:zone_id). func CreateCloudflareDNS(c *fiber.Ctx) error { zoneID := c.Params("zone_id") if zoneID == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id requerido"}) } var input services.CFDNSRecordInput if err := c.BodyParser(&input); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) } if input.Type == "" || input.Name == "" || input.Content == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type, name y content son requeridos"}) } if input.TTL == 0 { input.TTL = 1 // automático } client, err := cloudflareClient() if err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "error": "No se encontró configuración activa de Cloudflare", }) } record, err := client.CreateDNSRecord(zoneID, input) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.Status(fiber.StatusCreated).JSON(fiber.Map{"data": record}) } // UpdateCloudflareDNS actualiza (PUT completo) un registro DNS (:zone_id/:record_id). func UpdateCloudflareDNS(c *fiber.Ctx) error { zoneID := c.Params("zone_id") recordID := c.Params("record_id") if zoneID == "" || recordID == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id y record_id requeridos"}) } var input services.CFDNSRecordInput if err := c.BodyParser(&input); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) } if input.Type == "" || input.Name == "" || input.Content == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type, name y content son requeridos"}) } if input.TTL == 0 { input.TTL = 1 } client, err := cloudflareClient() if err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "error": "No se encontró configuración activa de Cloudflare", }) } record, err := client.UpdateDNSRecord(zoneID, recordID, input) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"data": record}) } // PatchCloudflareDNS actualiza parcialmente un registro DNS (:zone_id/:record_id). func PatchCloudflareDNS(c *fiber.Ctx) error { zoneID := c.Params("zone_id") recordID := c.Params("record_id") if zoneID == "" || recordID == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id y record_id requeridos"}) } var input services.CFDNSRecordInput if err := c.BodyParser(&input); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) } client, err := cloudflareClient() if err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "error": "No se encontró configuración activa de Cloudflare", }) } record, err := client.PatchDNSRecord(zoneID, recordID, input) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"data": record}) } // DeleteCloudflareDNS elimina un registro DNS (:zone_id/:record_id). func DeleteCloudflareDNS(c *fiber.Ctx) error { zoneID := c.Params("zone_id") recordID := c.Params("record_id") if zoneID == "" || recordID == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id y record_id requeridos"}) } client, err := cloudflareClient() if err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "error": "No se encontró configuración activa de Cloudflare", }) } if err := client.DeleteDNSRecord(zoneID, recordID); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"message": "Registro DNS eliminado"}) }