init
This commit is contained in:
commit
d69342b2e9
160 changed files with 28681 additions and 0 deletions
451
internal/cloudflare/analytics.go
Normal file
451
internal/cloudflare/analytics.go
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue