package goatcounter import ( "bytes" "context" "encoding/json" "io" "net/http" "time" "gitlab.com/tozd/go/errors" "go.alanpearce.eu/homestead/internal/config" "go.alanpearce.eu/x/log" ) const timeout = 5 * time.Second type Options struct { URL *config.URL Logger *log.Logger Token string } type URLs struct { count string } type Goatcounter struct { log *log.Logger token string client *http.Client urls URLs } type hit struct { IP string `json:"ip"` Path string `json:"path"` Query string `json:"query"` Referrer string `json:"ref"` Title string `json:"title"` UserAgent string `json:"user_agent"` } type countBody struct { NoSessions bool `json:"no_sessions"` Hits []hit `json:"hits"` } func New(options *Options) *Goatcounter { baseURL := options.URL baseURL.Path = "/api/v0/" return &Goatcounter{ log: options.Logger, token: options.Token, client: &http.Client{ Timeout: 5 * time.Second, }, urls: URLs{ count: baseURL.JoinPath("count").String(), }, } } func (gc *Goatcounter) Count(r *http.Request, title string) { err := gc.count(r, title) if err != nil { gc.log.Warn("could not log page view", "error", err) } } func (gc *Goatcounter) count(userReq *http.Request, title string) error { body, err := json.Marshal(&countBody{ NoSessions: true, Hits: []hit{ { IP: userReq.RemoteAddr, Path: userReq.URL.Path, Query: userReq.URL.RawQuery, Title: title, Referrer: userReq.Header.Get("Referer"), UserAgent: userReq.Header.Get("User-Agent"), }, }, }) if err != nil { return errors.WithMessage(err, "could not marshal JSON") } go func(body []byte) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() req, err := http.NewRequestWithContext( ctx, http.MethodPost, gc.urls.count, bytes.NewBuffer(body), ) if err != nil { gc.log.Warn("could not create HTTP request", "error", err) } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+gc.token) res, err := gc.client.Do(req) if err != nil { gc.log.Warn("could not perform HTTP request", "error", err) } clear(body) if res.Body != nil { defer res.Body.Close() body, err = io.ReadAll(res.Body) if err != nil { gc.log.Warn("could not read error body", "error", err) } } if res.StatusCode != http.StatusAccepted { gc.log.Warn("failed to log pageview", "status", res.StatusCode, "body", body) } }(body) return nil }