451 lines
10 KiB
Go
451 lines
10 KiB
Go
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
|
|
}
|