Es la base del cobro por uso: hasta ahora no había ninguna medición de consumo en todo el repo. - UmindUso registra cada evento facturable con el costo YA calculado al precio vigente del plan. Congelarlo evita que subir un precio revalúe consumo pasado, que haría indefendible una factura ante un reclamo. - callAI devuelve los tokens que reportó el proveedor (campo usage, igual en todos los OpenAI-compatibles; input+output en Anthropic). Se mide cada ronda de tool-calling, no solo la última: todas gastan tokens. - ExtraerTextoOCR y TranscribirAudioSelfHosted reciben agenteID; 0 = no medir, que es lo que pasan los botones "Probar" del panel de staff. - Aviso al superar el tope del plan, una vez por mes y sin cortar el servicio. El flag de "ya avisé" es en memoria a propósito. - GET /app/umind/uso con filtros de fecha: resumen por tipo + detalle. - Test del cálculo de costo por tipo, incluida fracción de 1k tokens y tenant sin plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
529 lines
18 KiB
Go
529 lines
18 KiB
Go
package services
|
||
|
||
import (
|
||
"bytes"
|
||
"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.
|
||
// Soporta dos modos de autenticación:
|
||
// - API Token (recomendado): Authorization: Bearer <token>
|
||
// - Global API Key (legacy): X-Auth-Email + X-Auth-Key
|
||
type CloudflareClient struct {
|
||
token string // API Token o Global API Key
|
||
email string // Solo para Global API Key
|
||
globalKey bool // true = usar X-Auth-Email + X-Auth-Key
|
||
accountID string
|
||
httpClient *http.Client
|
||
}
|
||
|
||
// NewCloudflareClient crea un cliente con API Token (Bearer).
|
||
func NewCloudflareClient(token, accountID string) *CloudflareClient {
|
||
return &CloudflareClient{
|
||
token: token,
|
||
accountID: accountID,
|
||
httpClient: &http.Client{
|
||
Timeout: 20 * time.Second,
|
||
},
|
||
}
|
||
}
|
||
|
||
// NewCloudflareClientGlobalKey crea un cliente con Global API Key (X-Auth-Email + X-Auth-Key).
|
||
func NewCloudflareClientGlobalKey(email, apiKey, accountID string) *CloudflareClient {
|
||
return &CloudflareClient{
|
||
token: apiKey,
|
||
email: email,
|
||
globalKey: true,
|
||
accountID: accountID,
|
||
httpClient: &http.Client{
|
||
Timeout: 20 * time.Second,
|
||
},
|
||
}
|
||
}
|
||
|
||
// IsGlobalKey indica si el cliente usa Global API Key en lugar de API Token.
|
||
func (c *CloudflareClient) IsGlobalKey() bool { return c.globalKey }
|
||
|
||
// addAuth aplica los headers de autenticación correctos según el tipo de credencial.
|
||
func (c *CloudflareClient) addAuth(req *http.Request) {
|
||
if c.globalKey {
|
||
req.Header.Set("X-Auth-Email", c.email)
|
||
req.Header.Set("X-Auth-Key", c.token)
|
||
} else {
|
||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
}
|
||
|
||
// get realiza una petición GET autenticada y decodifica el result.
|
||
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)
|
||
}
|
||
c.addAuth(req)
|
||
|
||
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"`
|
||
DocumentationURL string `json:"documentation_url,omitempty"`
|
||
}
|
||
|
||
// ─── 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"`
|
||
}
|
||
|
||
// CFZoneAccount representa la cuenta propietaria de la zona.
|
||
type CFZoneAccount struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
// 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"`
|
||
CreatedOn string `json:"created_on"`
|
||
ModifiedOn string `json:"modified_on"`
|
||
ActivatedOn string `json:"activated_on"`
|
||
Account CFZoneAccount `json:"account"`
|
||
Plan CFPlan `json:"plan"`
|
||
}
|
||
|
||
// CFPlan representa el plan de una zona.
|
||
type CFPlan struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
// CFDNSRecordSettings configuraciones adicionales de un registro DNS.
|
||
type CFDNSRecordSettings struct {
|
||
IPv4Only bool `json:"ipv4_only,omitempty"`
|
||
IPv6Only bool `json:"ipv6_only,omitempty"`
|
||
}
|
||
|
||
// CFDNSRecord representa un registro DNS.
|
||
type CFDNSRecord struct {
|
||
ID string `json:"id"`
|
||
Type string `json:"type"`
|
||
Name string `json:"name"`
|
||
Content string `json:"content"`
|
||
Comment string `json:"comment,omitempty"`
|
||
Tags []string `json:"tags,omitempty"`
|
||
Settings CFDNSRecordSettings `json:"settings,omitempty"`
|
||
PrivateRouting bool `json:"private_routing,omitempty"`
|
||
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"`
|
||
CommentModifiedOn string `json:"comment_modified_on,omitempty"`
|
||
TagsModifiedOn string `json:"tags_modified_on,omitempty"`
|
||
}
|
||
|
||
// CFSSLStatus representa un certificate pack de una zona.
|
||
type CFSSLStatus struct {
|
||
ID string `json:"id"`
|
||
Type string `json:"type"` // universal, advanced, custom, sni_custom
|
||
Hosts []string `json:"hosts"`
|
||
Status string `json:"status"` // active, pending_validation, deleted
|
||
ValidationMethod string `json:"validation_method,omitempty"`
|
||
ValidityDays int `json:"validity_days,omitempty"`
|
||
CertificateAuthority string `json:"certificate_authority,omitempty"`
|
||
Wildcard bool `json:"wildcard,omitempty"`
|
||
}
|
||
|
||
// CFUniversalSSL representa la configuración de Universal SSL de una zona.
|
||
type CFUniversalSSL struct {
|
||
Enabled bool `json:"enabled"`
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// CFTokenVerify resultado de la verificación del token.
|
||
type CFTokenVerify struct {
|
||
ID string `json:"id"`
|
||
Status string `json:"status"`
|
||
NotBefore string `json:"not_before"`
|
||
ExpiresOn string `json:"expires_on"`
|
||
}
|
||
|
||
// CFTokenPolicy representa una política de permisos del token.
|
||
type CFTokenPolicy struct {
|
||
ID string `json:"id"`
|
||
Effect string `json:"effect"`
|
||
Resources map[string]string `json:"resources"`
|
||
PermGroups []CFTokenPermGroup `json:"permission_groups"`
|
||
}
|
||
|
||
// CFTokenPermGroup un grupo de permisos.
|
||
type CFTokenPermGroup struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
// CFTokenDetail detalle completo de un token incluyendo sus políticas.
|
||
type CFTokenDetail struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Status string `json:"status"`
|
||
Policies []CFTokenPolicy `json:"policies"`
|
||
}
|
||
|
||
// VerifyToken verifica la validez del API token usando /user/tokens/verify.
|
||
func (c *CloudflareClient) VerifyToken() (*CFTokenVerify, error) {
|
||
var result CFTokenVerify
|
||
if err := c.get("/user/tokens/verify", &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
// GetTokenDetail obtiene el detalle completo de un token (permisos y recursos).
|
||
func (c *CloudflareClient) GetTokenDetail(tokenID string) (*CFTokenDetail, error) {
|
||
var detail CFTokenDetail
|
||
if err := c.get("/user/tokens/"+tokenID, &detail); err != nil {
|
||
return nil, err
|
||
}
|
||
return &detail, nil
|
||
}
|
||
|
||
// cfResultInfo contiene metadatos de paginación de la API de Cloudflare.
|
||
type cfResultInfo struct {
|
||
Page int `json:"page"`
|
||
PerPage int `json:"per_page"`
|
||
TotalPages int `json:"total_pages"`
|
||
Count int `json:"count"`
|
||
TotalCount int `json:"total_count"`
|
||
}
|
||
|
||
// getWithInfo realiza un GET y decodifica también result_info para paginación.
|
||
func (c *CloudflareClient) getWithInfo(path string, dest interface{}) (cfResultInfo, error) {
|
||
req, err := http.NewRequest(http.MethodGet, cloudflareBaseURL+path, nil)
|
||
if err != nil {
|
||
return cfResultInfo{}, fmt.Errorf("cloudflare: crear request: %w", err)
|
||
}
|
||
c.addAuth(req)
|
||
|
||
resp, err := c.httpClient.Do(req)
|
||
if err != nil {
|
||
return cfResultInfo{}, fmt.Errorf("cloudflare: ejecutar request: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return cfResultInfo{}, fmt.Errorf("cloudflare: leer body: %w", err)
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return cfResultInfo{}, fmt.Errorf("cloudflare: status %d – %s", resp.StatusCode, string(body))
|
||
}
|
||
|
||
var wrapper struct {
|
||
Success bool `json:"success"`
|
||
Errors []CFError `json:"errors"`
|
||
Result json.RawMessage `json:"result"`
|
||
ResultInfo cfResultInfo `json:"result_info"`
|
||
}
|
||
if err := json.Unmarshal(body, &wrapper); err != nil {
|
||
return cfResultInfo{}, fmt.Errorf("cloudflare: decodificar envelope: %w", err)
|
||
}
|
||
if !wrapper.Success {
|
||
if len(wrapper.Errors) > 0 {
|
||
return cfResultInfo{}, fmt.Errorf("cloudflare API error %d: %s", wrapper.Errors[0].Code, wrapper.Errors[0].Message)
|
||
}
|
||
return cfResultInfo{}, fmt.Errorf("cloudflare: respuesta no exitosa")
|
||
}
|
||
if dest != nil && wrapper.Result != nil {
|
||
if err := json.Unmarshal(wrapper.Result, dest); err != nil {
|
||
return cfResultInfo{}, fmt.Errorf("cloudflare: decodificar result: %w", err)
|
||
}
|
||
}
|
||
return wrapper.ResultInfo, nil
|
||
}
|
||
|
||
// GetZones lista todas las zonas de la cuenta, iterando páginas si es necesario.
|
||
// Si el cliente fue creado con accountID, filtra por esa cuenta.
|
||
func (c *CloudflareClient) GetZones() ([]CFZone, error) {
|
||
const perPage = 50
|
||
var all []CFZone
|
||
|
||
accountFilter := ""
|
||
if c.accountID != "" {
|
||
accountFilter = "&account.id=" + c.accountID
|
||
}
|
||
|
||
for page := 1; ; page++ {
|
||
path := fmt.Sprintf("/zones?per_page=%d&page=%d%s", perPage, page, accountFilter)
|
||
var zones []CFZone
|
||
info, err := c.getWithInfo(path, &zones)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
all = append(all, zones...)
|
||
if page >= info.TotalPages || info.TotalPages == 0 {
|
||
break
|
||
}
|
||
}
|
||
return all, nil
|
||
}
|
||
|
||
// GetDNSRecords devuelve todos los registros DNS de una zona, paginando automáticamente.
|
||
func (c *CloudflareClient) GetDNSRecords(zoneID string) ([]CFDNSRecord, error) {
|
||
const perPage = 5000
|
||
var all []CFDNSRecord
|
||
|
||
for page := 1; ; page++ {
|
||
path := fmt.Sprintf("/zones/%s/dns_records?per_page=%d&page=%d", zoneID, perPage, page)
|
||
var records []CFDNSRecord
|
||
info, err := c.getWithInfo(path, &records)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
all = append(all, records...)
|
||
if page >= info.TotalPages || info.TotalPages == 0 {
|
||
break
|
||
}
|
||
}
|
||
return all, nil
|
||
}
|
||
|
||
// GetSSLCertificates devuelve los certificate packs de una zona.
|
||
// Incluye ?status=all para ver packs en cualquier estado (requerido en planes Free).
|
||
func (c *CloudflareClient) GetSSLCertificates(zoneID string) ([]CFSSLStatus, error) {
|
||
var certs []CFSSLStatus
|
||
path := fmt.Sprintf("/zones/%s/ssl/certificate_packs?status=all", zoneID)
|
||
if err := c.get(path, &certs); err != nil {
|
||
return nil, err
|
||
}
|
||
return certs, nil
|
||
}
|
||
|
||
// GetUniversalSSLSettings devuelve si el Universal SSL está activo en la zona.
|
||
// Funciona en todos los planes. Útil como fallback cuando certificate_packs no está disponible.
|
||
func (c *CloudflareClient) GetUniversalSSLSettings(zoneID string) (*CFUniversalSSL, error) {
|
||
var result CFUniversalSSL
|
||
path := fmt.Sprintf("/zones/%s/ssl/universal/settings", zoneID)
|
||
if err := c.get(path, &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, 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
|
||
}
|
||
|
||
// ─── Helpers de escritura ────────────────────────────────────────────────────
|
||
|
||
// doRequest realiza una petición con body JSON y decodifica la respuesta.
|
||
func (c *CloudflareClient) doRequest(method, path string, payload interface{}, dest interface{}) error {
|
||
var bodyReader io.Reader
|
||
if payload != nil {
|
||
data, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return fmt.Errorf("cloudflare: serializar payload: %w", err)
|
||
}
|
||
bodyReader = bytes.NewReader(data)
|
||
}
|
||
|
||
req, err := http.NewRequest(method, cloudflareBaseURL+path, bodyReader)
|
||
if err != nil {
|
||
return fmt.Errorf("cloudflare: crear request: %w", err)
|
||
}
|
||
c.addAuth(req)
|
||
|
||
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))
|
||
}
|
||
|
||
// Para DELETE la respuesta puede ser vacía o tener solo {"success":true,"result":{"id":"..."}}
|
||
if len(body) == 0 {
|
||
return nil
|
||
}
|
||
|
||
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 entrada para DNS ───────────────────────────────────────────────
|
||
|
||
// CFDNSRecordInput es el payload para crear o actualizar un registro DNS.
|
||
type CFDNSRecordInput struct {
|
||
Type string `json:"type"` // A, AAAA, CNAME, TXT, MX, NS, SRV, CAA…
|
||
Name string `json:"name"` // Nombre del registro (ej. "www" o "@")
|
||
Content string `json:"content"` // Valor del registro
|
||
TTL int `json:"ttl"` // 1 = automático, o segundos (min 60)
|
||
Proxied bool `json:"proxied"` // true = nube naranja
|
||
Priority int `json:"priority,omitempty"` // Solo para MX / SRV
|
||
Comment string `json:"comment,omitempty"` // Comentario descriptivo
|
||
Tags []string `json:"tags,omitempty"` // Etiquetas (ej. ["owner:team"])
|
||
Settings *CFDNSRecordSettings `json:"settings,omitempty"` // Configuraciones adicionales
|
||
}
|
||
|
||
// ─── DNS CRUD ────────────────────────────────────────────────────────────────
|
||
|
||
// CreateDNSRecord crea un nuevo registro DNS en la zona indicada.
|
||
func (c *CloudflareClient) CreateDNSRecord(zoneID string, input CFDNSRecordInput) (*CFDNSRecord, error) {
|
||
var record CFDNSRecord
|
||
path := fmt.Sprintf("/zones/%s/dns_records", zoneID)
|
||
if err := c.doRequest(http.MethodPost, path, input, &record); err != nil {
|
||
return nil, err
|
||
}
|
||
return &record, nil
|
||
}
|
||
|
||
// UpdateDNSRecord actualiza (PUT completo) un registro DNS existente.
|
||
func (c *CloudflareClient) UpdateDNSRecord(zoneID, recordID string, input CFDNSRecordInput) (*CFDNSRecord, error) {
|
||
var record CFDNSRecord
|
||
path := fmt.Sprintf("/zones/%s/dns_records/%s", zoneID, recordID)
|
||
if err := c.doRequest(http.MethodPut, path, input, &record); err != nil {
|
||
return nil, err
|
||
}
|
||
return &record, nil
|
||
}
|
||
|
||
// PatchDNSRecord actualiza parcialmente (PATCH) un registro DNS existente.
|
||
func (c *CloudflareClient) PatchDNSRecord(zoneID, recordID string, input CFDNSRecordInput) (*CFDNSRecord, error) {
|
||
var record CFDNSRecord
|
||
path := fmt.Sprintf("/zones/%s/dns_records/%s", zoneID, recordID)
|
||
if err := c.doRequest(http.MethodPatch, path, input, &record); err != nil {
|
||
return nil, err
|
||
}
|
||
return &record, nil
|
||
}
|
||
|
||
// DeleteDNSRecord elimina un registro DNS por su ID.
|
||
func (c *CloudflareClient) DeleteDNSRecord(zoneID, recordID string) error {
|
||
path := fmt.Sprintf("/zones/%s/dns_records/%s", zoneID, recordID)
|
||
return c.doRequest(http.MethodDelete, path, nil, nil)
|
||
}
|