package goatcounter import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "slices" "time" "git.0xdad.com/tblyler/goatcounter-systemd/caddy" ) const ( MaxHitsPerRequest = 500 ) type Hit struct { Host string `json:"-"` Bot int `json:"bot,omitempty"` CreatedAt string `json:"created_at"` Event bool `json:"event"` IP string `json:"ip"` Location string `json:"location,omitempty"` Path string `json:"path"` Query string `json:"query,omitempty"` Referrer string `json:"ref,omitempty"` Session string `json:"session,omitempty"` ScreenSize string `json:"size,omitempty"` Title string `json:"title,omitempty"` UserAgent string `json:"user_agent,omitempty"` } type CountRequest struct { Filter []string `json:"filter,omitempty"` Hits []Hit `json:"hits"` NoSessions bool `json:"no_sessions"` } type Client struct { url *url.URL apiKey string httpClient *http.Client defaultHeaders http.Header } func HitFromCaddyLogEntry(logEntry caddy.LogEntry) Hit { path := "" query := "" requestURI, err := url.ParseRequestURI(logEntry.Request.URI) if err == nil { path = requestURI.Path query = requestURI.RawQuery } return Hit{ Bot: 0, Host: logEntry.Request.Host, CreatedAt: logEntry.Timestamp.Format(time.RFC3339Nano), Event: false, IP: logEntry.Request.ClientIP, Location: "", Path: path, Query: query, Referrer: logEntry.Request.Headers.Get("Referer"), Session: "", ScreenSize: "", Title: "", UserAgent: logEntry.Request.Headers.Get("User-Agent"), } } func NewClient(address, apiKey string) (*Client, error) { url, err := url.ParseRequestURI(address) if err != nil { return nil, fmt.Errorf("failed to parse address as URL %s: %w", address, err) } return &Client{ url: url, apiKey: apiKey, httpClient: &http.Client{}, defaultHeaders: http.Header{ "Content-Type": []string{"application/json"}, "Authorization": []string{"Bearer " + apiKey}, }, }, nil } func (c *Client) countURL() string { return c.url.JoinPath("/api/v0/count").String() } func (c *Client) URL() *url.URL { return c.url } func (c *Client) Count(ctx context.Context, hits ...Hit) (counted uint64, err error) { for hits := range slices.Chunk(hits, MaxHitsPerRequest) { if len(hits) == 0 { continue } body, err := json.Marshal(CountRequest{ NoSessions: true, Hits: hits, }) if err != nil { return counted, fmt.Errorf("failed to marshal count request: %w", err) } request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.countURL(), bytes.NewReader(body)) if err != nil { return counted, fmt.Errorf("failed to create count request: %w", err) } request.Header = c.defaultHeaders response, err := c.httpClient.Do(request) if err != nil { return counted, fmt.Errorf("failed to perform count request: %w", err) } if response.StatusCode != http.StatusAccepted { responseBody, _ := io.ReadAll(response.Body) return counted, fmt.Errorf("unexpected status code %d, response: %s", response.StatusCode, string(responseBody)) } counted += uint64(len(hits)) } return counted, nil }