feat: integración Hostinger + Cloudflare API (modelo, servicio, controlador, rutas, migración)

This commit is contained in:
Lizandro Guarnizo
2026-04-30 23:23:34 -05:00
parent ee1391db81
commit cb8e15ec4c
8 changed files with 829 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// CloudflareConfig almacena el API Token de Cloudflare.
// Solo un registro puede estar activo a la vez.
type CloudflareConfig struct {
gorm.Model
// API Token (recomendado) se usa como Bearer token
APIToken string `json:"api_token" gorm:"column:api_token;type:text;not null"`
// Account ID principal (opcional, para endpoints de cuenta)
AccountID string `json:"account_id" gorm:"column:account_id;type:text"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
Nota string `json:"nota" gorm:"column:nota;type:text"`
}
func (CloudflareConfig) TableName() string { return "cloudflare_config" }
// GetCloudflareConfig obtiene la configuración activa.
func GetCloudflareConfig() (*CloudflareConfig, error) {
var item CloudflareConfig
if err := app.Http.Database.DB.Where("activo = ?", true).Order("id DESC").First(&item).Error; err != nil {
return nil, err
}
return &item, nil
}
// SaveCloudflareConfig desactiva la config previa y guarda la nueva.
func SaveCloudflareConfig(s CloudflareConfig) error {
app.Http.Database.DB.Model(&CloudflareConfig{}).Where("activo = ?", true).
Update("activo", false)
s.Activo = true
if s.ID > 0 {
return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{
"api_token": s.APIToken,
"account_id": s.AccountID,
"nota": s.Nota,
"activo": true,
}).Error
}
return app.Http.Database.DB.Create(&s).Error
}
// GetAllCloudflareConfig lista todos los registros con paginación.
func GetAllCloudflareConfig(limit, offset int) ([]CloudflareConfig, int64, error) {
var items []CloudflareConfig
var total int64
db := app.Http.Database.DB.Model(&CloudflareConfig{})
db.Count(&total)
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
// DeleteCloudflareConfig elimina un registro.
func DeleteCloudflareConfig(id uint) error {
return app.Http.Database.DB.Delete(&CloudflareConfig{}, id).Error
}
+58
View File
@@ -0,0 +1,58 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// HostingerConfig almacena el token de acceso a la API de Hostinger.
// Solo un registro puede estar activo a la vez.
type HostingerConfig struct {
gorm.Model
Token string `json:"token" gorm:"column:token;type:text;not null"` // Bearer token de Hostinger
Activo bool `json:"activo" gorm:"column:activo;default:true"`
Nota string `json:"nota" gorm:"column:nota;type:text"`
}
func (HostingerConfig) TableName() string { return "hostinger_config" }
// GetHostingerConfig obtiene la configuración activa.
func GetHostingerConfig() (*HostingerConfig, error) {
var item HostingerConfig
if err := app.Http.Database.DB.Where("activo = ?", true).Order("id DESC").First(&item).Error; err != nil {
return nil, err
}
return &item, nil
}
// SaveHostingerConfig desactiva la config previa y guarda la nueva.
func SaveHostingerConfig(s HostingerConfig) error {
app.Http.Database.DB.Model(&HostingerConfig{}).Where("activo = ?", true).
Update("activo", false)
s.Activo = true
if s.ID > 0 {
return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{
"token": s.Token,
"nota": s.Nota,
"activo": true,
}).Error
}
return app.Http.Database.DB.Create(&s).Error
}
// GetAllHostingerConfig lista todos los registros con paginación.
func GetAllHostingerConfig(limit, offset int) ([]HostingerConfig, int64, error) {
var items []HostingerConfig
var total int64
db := app.Http.Database.DB.Model(&HostingerConfig{})
db.Count(&total)
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
// DeleteHostingerConfig elimina un registro.
func DeleteHostingerConfig(id uint) error {
return app.Http.Database.DB.Delete(&HostingerConfig{}, id).Error
}
+214
View File
@@ -0,0 +1,214 @@
package services
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const cloudflareBaseURL = "https://api.cloudflare.com/client/v4"
// CloudflareClient es el cliente HTTP para la API de Cloudflare.
type CloudflareClient struct {
token string
accountID string
httpClient *http.Client
}
// NewCloudflareClient crea un cliente con el API Token.
func NewCloudflareClient(token, accountID string) *CloudflareClient {
return &CloudflareClient{
token: token,
accountID: accountID,
httpClient: &http.Client{
Timeout: 20 * time.Second,
},
}
}
// get realiza una petición GET autenticada y devuelve el body.
func (c *CloudflareClient) get(path string, dest interface{}) error {
req, err := http.NewRequest(http.MethodGet, cloudflareBaseURL+path, nil)
if err != nil {
return fmt.Errorf("cloudflare: crear request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("cloudflare: ejecutar request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("cloudflare: leer body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("cloudflare: status %d %s", resp.StatusCode, string(body))
}
// Cloudflare siempre envuelve en {"result":..., "success":true}
var wrapper struct {
Success bool `json:"success"`
Errors []CFError `json:"errors"`
Result json.RawMessage `json:"result"`
}
if err := json.Unmarshal(body, &wrapper); err != nil {
return fmt.Errorf("cloudflare: decodificar envelope: %w", err)
}
if !wrapper.Success {
if len(wrapper.Errors) > 0 {
return fmt.Errorf("cloudflare API error %d: %s", wrapper.Errors[0].Code, wrapper.Errors[0].Message)
}
return fmt.Errorf("cloudflare: respuesta no exitosa")
}
if dest != nil && wrapper.Result != nil {
if err := json.Unmarshal(wrapper.Result, dest); err != nil {
return fmt.Errorf("cloudflare: decodificar result: %w", err)
}
}
return nil
}
// ─── Tipos de error ──────────────────────────────────────────────────────────
type CFError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// ─── Tipos de respuesta ──────────────────────────────────────────────────────
// CFUser información del usuario autenticado.
type CFUser struct {
ID string `json:"id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
}
// CFZone representa una zona (dominio) en Cloudflare.
type CFZone struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Paused bool `json:"paused"`
Type string `json:"type"`
NameServers []string `json:"name_servers"`
OriginalNS []string `json:"original_name_servers"`
ModifiedOn string `json:"modified_on"`
ActivatedOn string `json:"activated_on"`
Plan CFPlan `json:"plan"`
}
// CFPlan representa el plan de una zona.
type CFPlan struct {
ID string `json:"id"`
Name string `json:"name"`
}
// CFDNSRecord representa un registro DNS.
type CFDNSRecord struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
Proxied bool `json:"proxied"`
Proxiable bool `json:"proxiable"`
TTL int `json:"ttl"`
Priority int `json:"priority,omitempty"`
CreatedOn string `json:"created_on"`
ModifiedOn string `json:"modified_on"`
}
// CFSSLStatus representa el estado SSL de una zona.
type CFSSLStatus struct {
ID string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
Hosts []string `json:"hosts"`
PrimaryCert string `json:"primary_certificate"`
ExpiresOn string `json:"expires_on"`
}
// CFFirewallRule representa una regla de firewall (Access Rules).
type CFFirewallRule struct {
ID string `json:"id"`
Notes string `json:"notes"`
AllowedModes []string `json:"allowed_modes"`
Mode string `json:"mode"`
Configuration CFFirewallCfg `json:"configuration"`
Scope CFScope `json:"scope"`
CreatedOn string `json:"created_on"`
ModifiedOn string `json:"modified_on"`
}
// CFFirewallCfg configuración del target en la regla.
type CFFirewallCfg struct {
Target string `json:"target"`
Value string `json:"value"`
}
// CFScope scope de la regla (zone o account).
type CFScope struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
}
// ─── Métodos de la API ───────────────────────────────────────────────────────
// GetUser obtiene la información del usuario autenticado.
func (c *CloudflareClient) GetUser() (*CFUser, error) {
var user CFUser
if err := c.get("/user", &user); err != nil {
return nil, err
}
return &user, nil
}
// GetZones lista todas las zonas de la cuenta.
func (c *CloudflareClient) GetZones() ([]CFZone, error) {
var zones []CFZone
// pagina 1, 50 por página (máximo)
if err := c.get("/zones?per_page=50&page=1", &zones); err != nil {
return nil, err
}
return zones, nil
}
// GetDNSRecords devuelve los registros DNS de una zona.
func (c *CloudflareClient) GetDNSRecords(zoneID string) ([]CFDNSRecord, error) {
var records []CFDNSRecord
path := fmt.Sprintf("/zones/%s/dns_records?per_page=100&page=1", zoneID)
if err := c.get(path, &records); err != nil {
return nil, err
}
return records, nil
}
// GetSSLCertificates devuelve los certificate packs de una zona.
func (c *CloudflareClient) GetSSLCertificates(zoneID string) ([]CFSSLStatus, error) {
var certs []CFSSLStatus
path := fmt.Sprintf("/zones/%s/ssl/certificate_packs", zoneID)
if err := c.get(path, &certs); err != nil {
return nil, err
}
return certs, nil
}
// GetFirewallRules devuelve las reglas de acceso IP de una zona.
func (c *CloudflareClient) GetFirewallRules(zoneID string) ([]CFFirewallRule, error) {
var rules []CFFirewallRule
path := fmt.Sprintf("/zones/%s/firewall/access_rules/rules?per_page=50&page=1", zoneID)
if err := c.get(path, &rules); err != nil {
return nil, err
}
return rules, nil
}
+192
View File
@@ -0,0 +1,192 @@
package services
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const hostingerBaseURL = "https://developers.hostinger.com/api"
// HostingerClient es el cliente HTTP para la API de Hostinger.
type HostingerClient struct {
token string
httpClient *http.Client
}
// NewHostingerClient crea un cliente con el token Bearer.
func NewHostingerClient(token string) *HostingerClient {
return &HostingerClient{
token: token,
httpClient: &http.Client{
Timeout: 20 * time.Second,
},
}
}
// get realiza una petición GET autenticada y decodifica el cuerpo en dest.
func (c *HostingerClient) get(path string, dest interface{}) error {
req, err := http.NewRequest(http.MethodGet, hostingerBaseURL+path, nil)
if err != nil {
return fmt.Errorf("hostinger: crear request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("hostinger: ejecutar request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("hostinger: leer body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("hostinger: status %d %s", resp.StatusCode, string(body))
}
if dest != nil {
if err := json.Unmarshal(body, dest); err != nil {
return fmt.Errorf("hostinger: decodificar respuesta: %w", err)
}
}
return nil
}
// ─── Tipos de respuesta ──────────────────────────────────────────────────────
// HostingerVPS representa una máquina virtual VPS.
type HostingerVPS struct {
ID int `json:"id"`
Hostname string `json:"hostname"`
State string `json:"state"`
Plan string `json:"plan"`
DataCenter string `json:"data_center"`
CPU int `json:"cpus"`
RAMBytes int64 `json:"ram"`
DiskBytes int64 `json:"disk"`
IPV4 string `json:"ipv4"`
IPV6 string `json:"ipv6"`
}
// HostingerDomain representa un dominio en el portafolio.
type HostingerDomain struct {
Domain string `json:"domain"`
Status string `json:"status"`
ExpiresAt string `json:"expires_at"`
RegisteredAt string `json:"registered_at"`
}
// HostingerDNSRecord representa un registro DNS.
type HostingerDNSRecord struct {
ID int `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
TTL int `json:"ttl"`
Priority int `json:"priority,omitempty"`
}
// HostingerOrder representa una orden de facturación.
type HostingerOrder struct {
ID int `json:"id"`
Status string `json:"status"`
Total float64 `json:"total"`
Currency string `json:"currency"`
CreatedAt string `json:"created_at"`
}
// HostingerHosting representa una cuenta de hosting.
type HostingerHosting struct {
ID int `json:"id"`
Domain string `json:"domain"`
Plan string `json:"plan"`
State string `json:"state"`
ExpiresAt string `json:"expires_at"`
DiskUsed int64 `json:"disk_used"`
DiskLimit int64 `json:"disk_limit"`
}
// ─── Métodos de la API ───────────────────────────────────────────────────────
// GetVPSList obtiene la lista de VPS/VMs.
func (c *HostingerClient) GetVPSList() ([]HostingerVPS, error) {
var result struct {
Data []HostingerVPS `json:"data"`
}
if err := c.get("/vps/v1/virtual-machines", &result); err != nil {
// intenta respuesta array directa
var arr []HostingerVPS
if err2 := c.get("/vps/v1/virtual-machines", &arr); err2 != nil {
return nil, err
}
return arr, nil
}
return result.Data, nil
}
// GetDomains obtiene el portafolio de dominios.
func (c *HostingerClient) GetDomains() ([]HostingerDomain, error) {
var result struct {
Data []HostingerDomain `json:"data"`
}
if err := c.get("/domains/v1/portfolio", &result); err != nil {
var arr []HostingerDomain
if err2 := c.get("/domains/v1/portfolio", &arr); err2 != nil {
return nil, err
}
return arr, nil
}
return result.Data, nil
}
// GetDNSRecords obtiene los registros DNS de un dominio.
func (c *HostingerClient) GetDNSRecords(domain string) ([]HostingerDNSRecord, error) {
var result struct {
Data []HostingerDNSRecord `json:"data"`
}
path := fmt.Sprintf("/dns/v1/zones/%s/records", domain)
if err := c.get(path, &result); err != nil {
var arr []HostingerDNSRecord
if err2 := c.get(path, &arr); err2 != nil {
return nil, err
}
return arr, nil
}
return result.Data, nil
}
// GetOrders obtiene las órdenes de facturación.
func (c *HostingerClient) GetOrders() ([]HostingerOrder, error) {
var result struct {
Data []HostingerOrder `json:"data"`
}
if err := c.get("/billing/v1/orders", &result); err != nil {
var arr []HostingerOrder
if err2 := c.get("/billing/v1/orders", &arr); err2 != nil {
return nil, err
}
return arr, nil
}
return result.Data, nil
}
// GetHostingAccounts obtiene las cuentas de hosting.
func (c *HostingerClient) GetHostingAccounts() ([]HostingerHosting, error) {
var result struct {
Data []HostingerHosting `json:"data"`
}
if err := c.get("/hosting/v1/accounts", &result); err != nil {
var arr []HostingerHosting
if err2 := c.get("/hosting/v1/accounts", &arr); err2 != nil {
return nil, err
}
return arr, nil
}
return result.Data, nil
}