all repos — homestead @ 522324c7b7491a359d7a37931bfc2da402d014b0

Code for my website

move goatcounter integration to server-side

Alan Pearce
commit

522324c7b7491a359d7a37931bfc2da402d014b0

parent

6db99a292ad6a3ba7c5fedce1a4337d3ceac78af

M internal/builder/builder.gointernal/builder/builder.go
@@ -245,7 +245,6 @@ if cfg == nil {
return errors.New("config is nil") } cfg.InjectLiveReload = options.Development - cfg.EnableGoatCounter = !options.Development templates.Init()
M internal/config/config.gointernal/config/config.go
@@ -32,22 +32,21 @@ return errors.WithMessagef(err, "could not parse URL %s", string(text))
} type Config struct { - DefaultLanguage string `toml:"default_language"` - BaseURL URL `toml:"base_url"` - InjectLiveReload bool - EnableGoatCounter bool `toml:"enable_goatcounter"` - Title string - Email string - Description string - DomainStartDate string `toml:"domain_start_date"` - OriginalDomain string `toml:"original_domain"` - GoatCounter URL `toml:"goatcounter"` - Domains []string - WildcardDomain string `toml:"wildcard_domain"` - OIDCHost URL `toml:"oidc_host"` - Taxonomies []Taxonomy - CSP *CSP `toml:"content-security-policy"` - Extra struct { + DefaultLanguage string `toml:"default_language"` + BaseURL URL `toml:"base_url"` + InjectLiveReload bool + Title string + Email string + Description string + DomainStartDate string `toml:"domain_start_date"` + OriginalDomain string `toml:"original_domain"` + GoatCounter URL `toml:"goatcounter"` + Domains []string + WildcardDomain string `toml:"wildcard_domain"` + OIDCHost URL `toml:"oidc_host"` + Taxonomies []Taxonomy + CSP *CSP `toml:"content-security-policy"` + Extra struct { Headers map[string]string } Menus map[string][]MenuItem
A internal/stats/counter.go
@@ -0,0 +1,7 @@
+package stats + +import "net/http" + +type Counter interface { + Count(*http.Request) +}
A internal/stats/goatcounter/count.go
@@ -0,0 +1,124 @@
+package goatcounter + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "gitlab.com/tozd/go/errors" + "go.alanpearce.eu/homestead/internal/config" + "go.alanpearce.eu/x/log" +) + +const timeout = 1 * 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"` + 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 + if strings.HasSuffix(baseURL.Path, "/count") { + 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) { + err := gc.count(r) + if err != nil { + gc.log.Warn("could not log page view", "error", err) + } +} + +func (gc *Goatcounter) count(userReq *http.Request) error { + body, err := json.Marshal(&countBody{ + NoSessions: true, + Hits: []hit{ + { + IP: userReq.RemoteAddr, + Path: userReq.URL.Path, + Query: userReq.URL.RawQuery, + Referrer: userReq.Header.Get("Referer"), + UserAgent: userReq.Header.Get("User-Agent"), + }, + }, + }) + if err != nil { + return errors.WithMessage(err, "could not marshal JSON") + } + + 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 { + return errors.WithMessage(err, "could not create HTTP request") + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+gc.token) + + go func(req *http.Request) { + res, err := gc.client.Do(req) + if err != nil { + gc.log.Warn("could not perform HTTP request", "error", err) + } + 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) + } + + }(req) + + return nil +}
M internal/website/mux.gointernal/website/mux.go
@@ -48,6 +48,9 @@ http.Redirect(w, r, urlPath, 302)
return nil } + if website.counter != nil { + website.counter.Count(r) + } file, err := website.reader.GetFile(urlPath) if err != nil { website.log.Warn("Error reading file", "error", err)
M internal/website/website.gointernal/website/website.go
@@ -13,6 +13,8 @@ "go.alanpearce.eu/homestead/internal/fetcher"
"go.alanpearce.eu/homestead/internal/file" ihttp "go.alanpearce.eu/homestead/internal/http" "go.alanpearce.eu/homestead/internal/server" + "go.alanpearce.eu/homestead/internal/stats" + "go.alanpearce.eu/homestead/internal/stats/goatcounter" "go.alanpearce.eu/homestead/internal/storage" "go.alanpearce.eu/homestead/internal/storage/sqlite" "go.alanpearce.eu/x/log"
@@ -22,12 +24,13 @@ "github.com/osdevisnot/sorvor/pkg/livereload"
) type Options struct { - DataRoot string `conf:"noprint"` - Root string `conf:"default:./website"` - Redirect bool `conf:"default:true"` - Development bool `conf:"default:false,flag:dev"` - FetchURL config.URL `conf:"default:https://ci.alanpearce.eu/archive/website/"` - BaseURL config.URL + DataRoot string `conf:"noprint"` + Root string `conf:"default:./website"` + Redirect bool `conf:"default:true"` + Development bool `conf:"default:false,flag:dev"` + FetchURL config.URL `conf:"default:https://ci.alanpearce.eu/archive/website/"` + BaseURL config.URL + GoatcounterToken string Redis *events.RedisOptions LiveReload *livereload.LiveReload `conf:"-"`
@@ -35,6 +38,7 @@ }
type Website struct { config *config.Config + counter stats.Counter log *log.Logger reader storage.Reader me digit.Resource
@@ -99,6 +103,17 @@
if opts.Development { cfg.CSP.ScriptSrc = slices.Insert(cfg.CSP.ScriptSrc, 0, "'unsafe-inline'") cfg.CSP.ConnectSrc = slices.Insert(cfg.CSP.ConnectSrc, 0, "'self'") + } + if opts.GoatcounterToken == "" { + if !opts.Development { + log.Warn("in production without a goatcounter token") + } + } else { + website.counter = goatcounter.New(&goatcounter.Options{ + Logger: log.Named("counter"), + URL: &cfg.GoatCounter, + Token: opts.GoatcounterToken, + }) } if opts.BaseURL.URL != nil && opts.BaseURL.Hostname() != "" {
M templates/layout.templtemplates/layout.templ
@@ -62,9 +62,6 @@ <a rel="license" href="http://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>.
<a href="https://git.alanpearce.eu/website/">Site source code</a> is <a href="https://opensource.org/licenses/MIT">MIT</a> </footer> - if site.EnableGoatCounter { - @counter(site, page.Path, page.Title) - } if site.InjectLiveReload { <script defer> new EventSource("/_/reload").onmessage = event => {
@@ -88,13 +85,6 @@ q.Add("t", title)
u.RawQuery = q.Encode() return u.String() -} - -templ counter(config *config.Config, path string, title string) { - <script data-goatcounter={ config.GoatCounter.String() } async src="https://stats.alanpearce.eu/count.v4.js" crossorigin="anonymous" integrity="sha384-nRw6qfbWyJha9LhsOtSb2YJDyZdKvvCFh0fJYlkquSFjUxp9FVNugbfy8q1jdxI+"></script> - <noscript> - <img src={ string(templ.URL(mkURL(config.GoatCounter, path, title))) }/> - </noscript> } func style(css string) templ.Component {