137 lines
3.2 KiB
Go
137 lines
3.2 KiB
Go
|
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
|
||
|
}
|