package cloudflare import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "sync" "time" ) type Client struct { apiToken string zoneID string client *http.Client cache *analyticsCache } type analyticsCache struct { mu sync.RWMutex data map[string]*cachedResult ttl time.Duration } type cachedResult struct { result *ZoneAnalytics expiresAt time.Time } func NewClient() *Client { return &Client{ apiToken: os.Getenv("CLOUDFLARE_API_TOKEN"), zoneID: os.Getenv("CLOUDFLARE_ZONE_ID"), client: &http.Client{Timeout: 30 * time.Second}, cache: &analyticsCache{ data: make(map[string]*cachedResult), ttl: 5 * time.Minute, }, } } func (c *Client) IsConfigured() bool { return c.apiToken != "" && c.zoneID != "" } type ZoneAnalytics struct { TotalRequests int64 `json:"totalRequests"` TotalPageViews int64 `json:"totalPageViews"` UniqueVisitors int64 `json:"uniqueVisitors"` TotalBandwidth int64 `json:"totalBandwidth"` Daily []DailyStats `json:"daily"` Browsers []NamedCount `json:"browsers"` OS []NamedCount `json:"os"` Devices []NamedCount `json:"devices"` Countries []NamedCount `json:"countries"` Paths []PathStats `json:"paths"` } type DailyStats struct { Date string `json:"date"` Requests int64 `json:"requests"` PageViews int64 `json:"pageViews"` Visitors int64 `json:"visitors"` Bandwidth int64 `json:"bandwidth"` } type NamedCount struct { Name string `json:"name"` Count int64 `json:"count"` } type PathStats struct { Path string `json:"path"` Requests int64 `json:"requests"` } type graphqlRequest struct { Query string `json:"query"` Variables map[string]any `json:"variables,omitempty"` } type graphqlResponse struct { Data json.RawMessage `json:"data"` Errors []struct { Message string `json:"message"` } `json:"errors,omitempty"` } func (c *Client) GetAnalytics(ctx context.Context, days int, hostname string) (*ZoneAnalytics, error) { if !c.IsConfigured() { return nil, fmt.Errorf("cloudflare not configured") } cacheKey := fmt.Sprintf("%s:%d:%s", c.zoneID, days, hostname) if cached := c.cache.get(cacheKey); cached != nil { return cached, nil } since := time.Now().AddDate(0, 0, -days).Format("2006-01-02") until := time.Now().Format("2006-01-02") result := &ZoneAnalytics{} dailyData, err := c.fetchDailyStats(ctx, since, until, hostname) if err != nil { return nil, err } result.Daily = dailyData for _, d := range dailyData { result.TotalRequests += d.Requests result.TotalPageViews += d.PageViews result.UniqueVisitors += d.Visitors result.TotalBandwidth += d.Bandwidth } browsers, _ := c.fetchGroupedStats(ctx, since, until, hostname, "clientRequestHTTPHost", "userAgentBrowser") result.Browsers = browsers osStats, _ := c.fetchGroupedStats(ctx, since, until, hostname, "clientRequestHTTPHost", "userAgentOS") result.OS = osStats devices, _ := c.fetchGroupedStats(ctx, since, until, hostname, "clientRequestHTTPHost", "deviceType") result.Devices = devices countries, _ := c.fetchGroupedStats(ctx, since, until, hostname, "clientRequestHTTPHost", "clientCountryName") result.Countries = countries paths, _ := c.fetchPathStats(ctx, since, until, hostname) result.Paths = paths c.cache.set(cacheKey, result) return result, nil } func (c *Client) fetchDailyStats(ctx context.Context, since, until, hostname string) ([]DailyStats, error) { query := ` query ($zoneTag: String!, $since: Date!, $until: Date!, $hostname: String!) { viewer { zones(filter: { zoneTag: $zoneTag }) { httpRequests1dGroups( filter: { date_geq: $since, date_leq: $until, clientRequestHTTPHost: $hostname } orderBy: [date_ASC] limit: 100 ) { dimensions { date } sum { requests pageViews bytes } uniq { uniques } } } } } ` vars := map[string]any{ "zoneTag": c.zoneID, "since": since, "until": until, "hostname": hostname, } resp, err := c.doQuery(ctx, query, vars) if err != nil { return nil, err } var data struct { Viewer struct { Zones []struct { HttpRequests1dGroups []struct { Dimensions struct { Date string `json:"date"` } `json:"dimensions"` Sum struct { Requests int64 `json:"requests"` PageViews int64 `json:"pageViews"` Bytes int64 `json:"bytes"` } `json:"sum"` Uniq struct { Uniques int64 `json:"uniques"` } `json:"uniq"` } `json:"httpRequests1dGroups"` } `json:"zones"` } `json:"viewer"` } if err := json.Unmarshal(resp, &data); err != nil { return nil, fmt.Errorf("parse response: %w", err) } var results []DailyStats if len(data.Viewer.Zones) > 0 { for _, g := range data.Viewer.Zones[0].HttpRequests1dGroups { results = append(results, DailyStats{ Date: g.Dimensions.Date, Requests: g.Sum.Requests, PageViews: g.Sum.PageViews, Visitors: g.Uniq.Uniques, Bandwidth: g.Sum.Bytes, }) } } return results, nil } func (c *Client) fetchGroupedStats(ctx context.Context, since, until, hostname, hostField, groupBy string) ([]NamedCount, error) { query := fmt.Sprintf(` query ($zoneTag: String!, $since: Date!, $until: Date!, $hostname: String!) { viewer { zones(filter: { zoneTag: $zoneTag }) { httpRequests1dGroups( filter: { date_geq: $since, date_leq: $until, %s: $hostname } orderBy: [sum_requests_DESC] limit: 10 ) { dimensions { %s } sum { requests } } } } } `, hostField, groupBy) vars := map[string]any{ "zoneTag": c.zoneID, "since": since, "until": until, "hostname": hostname, } resp, err := c.doQuery(ctx, query, vars) if err != nil { return nil, err } var data struct { Viewer struct { Zones []struct { HttpRequests1dGroups []struct { Dimensions map[string]string `json:"dimensions"` Sum struct { Requests int64 `json:"requests"` } `json:"sum"` } `json:"httpRequests1dGroups"` } `json:"zones"` } `json:"viewer"` } if err := json.Unmarshal(resp, &data); err != nil { return nil, fmt.Errorf("parse response: %w", err) } var results []NamedCount if len(data.Viewer.Zones) > 0 { for _, g := range data.Viewer.Zones[0].HttpRequests1dGroups { name := g.Dimensions[groupBy] if name == "" { name = "Unknown" } results = append(results, NamedCount{ Name: name, Count: g.Sum.Requests, }) } } return results, nil } func (c *Client) fetchPathStats(ctx context.Context, since, until, hostname string) ([]PathStats, error) { query := ` query ($zoneTag: String!, $since: Date!, $until: Date!, $hostname: String!) { viewer { zones(filter: { zoneTag: $zoneTag }) { httpRequests1dGroups( filter: { date_geq: $since, date_leq: $until, clientRequestHTTPHost: $hostname } orderBy: [sum_requests_DESC] limit: 20 ) { dimensions { clientRequestPath } sum { requests } } } } } ` vars := map[string]any{ "zoneTag": c.zoneID, "since": since, "until": until, "hostname": hostname, } resp, err := c.doQuery(ctx, query, vars) if err != nil { return nil, err } var data struct { Viewer struct { Zones []struct { HttpRequests1dGroups []struct { Dimensions struct { Path string `json:"clientRequestPath"` } `json:"dimensions"` Sum struct { Requests int64 `json:"requests"` } `json:"sum"` } `json:"httpRequests1dGroups"` } `json:"zones"` } `json:"viewer"` } if err := json.Unmarshal(resp, &data); err != nil { return nil, fmt.Errorf("parse response: %w", err) } var results []PathStats if len(data.Viewer.Zones) > 0 { for _, g := range data.Viewer.Zones[0].HttpRequests1dGroups { results = append(results, PathStats{ Path: g.Dimensions.Path, Requests: g.Sum.Requests, }) } } return results, nil } func (c *Client) doQuery(ctx context.Context, query string, vars map[string]any) (json.RawMessage, error) { reqBody := graphqlRequest{ Query: query, Variables: vars, } body, err := json.Marshal(reqBody) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, "POST", "https://api.cloudflare.com/client/v4/graphql", bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.apiToken) resp, err := c.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("cloudflare API error: %s", string(respBody)) } var gqlResp graphqlResponse if err := json.Unmarshal(respBody, &gqlResp); err != nil { return nil, err } if len(gqlResp.Errors) > 0 { return nil, fmt.Errorf("graphql error: %s", gqlResp.Errors[0].Message) } return gqlResp.Data, nil } func (c *analyticsCache) get(key string) *ZoneAnalytics { c.mu.RLock() defer c.mu.RUnlock() if cached, ok := c.data[key]; ok && time.Now().Before(cached.expiresAt) { return cached.result } return nil } func (c *analyticsCache) set(key string, result *ZoneAnalytics) { c.mu.Lock() defer c.mu.Unlock() c.data[key] = &cachedResult{ result: result, expiresAt: time.Now().Add(c.ttl), } } func (c *Client) PurgeURLs(ctx context.Context, urls []string) error { if !c.IsConfigured() || len(urls) == 0 { return nil } body, err := json.Marshal(map[string][]string{"files": urls}) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/purge_cache", c.zoneID), bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.apiToken) resp, err := c.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("cloudflare purge failed: %s", string(respBody)) } return nil }