Files
VeillaxandClaude Sonnet 5.5 b4fd31a182
Release / release (push) Successful in 3m49s
CI / test (push) Successful in 4m45s
Add website analytics
A Tianji-style Websites section: a small tracking script, a public collection endpoint, and a
page per website with visitors, page views, visits, bounce rate, a chart and top lists for pages,
referrers, browsers, systems, devices, countries, languages and events. No cookies and no stored
IP address or user agent. SANDBAR_TRUST_PROXY makes the client address come from the proxy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 04:22:55 -06:00

432 lines
13 KiB
Go

package sandbar
import (
"crypto/sha256"
_ "embed"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"time"
)
// Website analytics, in the manner of Tianji, Plausible and Umami: a small script on a site sends
// page views and custom events here, and the Websites page shows visitors, pages, referrers,
// browsers, systems, devices, countries and events.
//
// Privacy by construction: no cookies, no stored IP address or user agent. A visitor is a hash of the
// IP address, the user agent and the website, salted with a secret and the UTC date, so the same
// person is the same visitor within a day and cannot be followed beyond it.
//go:embed web/script.js
var trackerJS []byte
// Website is a site whose visits are counted.
type Website struct {
ID string `json:"id"`
Name string `json:"name"`
Domain string `json:"domain"` // optional: when set, only hits from this host and its subdomains count
}
func prepareWebsite(v any, id string) error {
w := v.(*Website)
w.ID = id
w.Name = strings.TrimSpace(w.Name)
if w.Name == "" {
return errors.New("give the website a name")
}
d := strings.ToLower(strings.TrimSpace(w.Domain))
if d != "" {
if !strings.Contains(d, "//") {
d = "//" + d
}
u, err := url.Parse(d)
if err != nil || u.Hostname() == "" {
return errors.New("the domain should look like example.com")
}
d = u.Hostname()
}
w.Domain = d
return nil
}
func (s *Server) deleteWebsiteData(id string) {
s.st.db.Exec(`DELETE FROM hits WHERE website=?`, id)
}
// realIP is the address of the client. Behind a reverse proxy, set TrustProxy so the address the
// proxy added (the last entry of X-Forwarded-For) is used; otherwise every visitor looks like the proxy.
func (s *Server) realIP(r *http.Request) string {
if s.opts.TrustProxy {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
if ip := strings.TrimSpace(parts[len(parts)-1]); net.ParseIP(ip) != nil {
return ip
}
}
if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); net.ParseIP(ip) != nil {
return ip
}
}
h, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return h
}
func (s *Server) serveTracker(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Write(trackerJS)
}
var botRe = regexp.MustCompile(`(?i)bot|crawl|spider|slurp|headless|lighthouse|preview|monitor|curl/|wget|python-requests|go-http-client|okhttp`)
// parseUA reduces a user agent to a browser, a system and a kind of device. Only these labels are stored.
func parseUA(ua string) (browser, os, device string) {
switch {
case strings.Contains(ua, "Edg/") || strings.Contains(ua, "EdgA/") || strings.Contains(ua, "EdgiOS/"):
browser = "Edge"
case strings.Contains(ua, "OPR/") || strings.Contains(ua, "Opera"):
browser = "Opera"
case strings.Contains(ua, "SamsungBrowser"):
browser = "Samsung Internet"
case strings.Contains(ua, "Firefox/") || strings.Contains(ua, "FxiOS/"):
browser = "Firefox"
case strings.Contains(ua, "Chrome/") || strings.Contains(ua, "CriOS/"):
browser = "Chrome"
case strings.Contains(ua, "Safari/"):
browser = "Safari"
default:
browser = "Other"
}
switch {
case strings.Contains(ua, "Windows"):
os = "Windows"
case strings.Contains(ua, "Android"):
os = "Android"
case strings.Contains(ua, "iPhone") || strings.Contains(ua, "iPad") || strings.Contains(ua, "iPod"):
os = "iOS"
case strings.Contains(ua, "Mac OS X") || strings.Contains(ua, "Macintosh"):
os = "macOS"
case strings.Contains(ua, "CrOS"):
os = "ChromeOS"
case strings.Contains(ua, "Linux") || strings.Contains(ua, "X11"):
os = "Linux"
default:
os = "Other"
}
switch {
case strings.Contains(ua, "iPad") || strings.Contains(ua, "Tablet") || (strings.Contains(ua, "Android") && !strings.Contains(ua, "Mobile")):
device = "Tablet"
case strings.Contains(ua, "Mobi") || strings.Contains(ua, "iPhone"):
device = "Mobile"
default:
device = "Desktop"
}
return
}
// secret is the random value that salts visitor hashes. It is made once and kept with the other settings.
func (s *Server) analyticsSecret() string {
s.anaMu.Lock()
defer s.anaMu.Unlock()
if s.anaSecret != "" {
return s.anaSecret
}
var v struct{ Secret string }
if s.st.get("setting", "analytics", &v) != nil || v.Secret == "" {
v.Secret = newToken()
s.st.put("setting", "analytics", &v)
}
s.anaSecret = v.Secret
return v.Secret
}
func (s *Server) visitorID(website, ip, ua string, now time.Time) string {
h := sha256.Sum256([]byte(s.analyticsSecret() + "|" + now.UTC().Format("2006-01-02") + "|" + website + "|" + ip + "|" + ua))
return hex.EncodeToString(h[:8])
}
// hitAllowed limits how fast one address can send hits: a script that loops must not fill the database.
func (s *Server) hitAllowed(ip string) bool {
now := s.now()
s.anaMu.Lock()
defer s.anaMu.Unlock()
if len(s.anaTry) > 20000 {
s.anaTry = map[string][]time.Time{}
}
recent := s.anaTry[ip][:0]
for _, t := range s.anaTry[ip] {
if now.Sub(t) < time.Minute {
recent = append(recent, t)
}
}
if len(recent) >= 120 {
s.anaTry[ip] = recent
return false
}
s.anaTry[ip] = append(recent, now)
return true
}
func clip(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) > n {
s = strings.ToValidUTF8(s[:n], "")
}
return s
}
// collect receives a page view or event from the tracker script. It is public by design, so it
// answers 204 to everything it ignores and gives nothing away.
func (s *Server) collect(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(204)
return
}
defer w.WriteHeader(204)
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4<<10))
if err != nil {
return
}
var in struct {
Website, Path, Referrer, Hostname, Language, Event string
Data map[string]any
}
if json.Unmarshal(body, &in) != nil {
return
}
var site Website
if s.st.get("website", in.Website, &site) != nil {
return
}
host := strings.ToLower(in.Hostname)
if site.Domain != "" && host != site.Domain && !strings.HasSuffix(host, "."+site.Domain) {
return
}
ua := r.UserAgent()
if ua == "" || botRe.MatchString(ua) {
return
}
ip := s.realIP(r)
if !s.hitAllowed(ip) {
return
}
path := in.Path
if i := strings.IndexAny(path, "?"); i >= 0 {
path = path[:i]
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
ref := ""
if u, err := url.Parse(in.Referrer); err == nil && u.Host != "" {
if rh := strings.TrimPrefix(strings.ToLower(u.Hostname()), "www."); rh != strings.TrimPrefix(host, "www.") {
ref = rh
}
}
country := strings.ToUpper(clip(r.Header.Get("CF-IPCountry"), 2))
if country == "" {
country = strings.ToUpper(clip(r.Header.Get("CloudFront-Viewer-Country"), 2))
}
if country == "XX" || country == "T1" {
country = ""
}
lang := strings.ToLower(clip(in.Language, 12))
if i := strings.IndexAny(lang, "-_"); i > 0 {
lang = lang[:i]
}
data := ""
if in.Event != "" && len(in.Data) > 0 {
if b, err := json.Marshal(in.Data); err == nil && len(b) <= 1024 {
data = string(b)
}
}
browser, osName, device := parseUA(ua)
now := s.now()
s.st.db.Exec(`INSERT INTO hits(website,time,visitor,event,path,referrer,browser,os,device,country,lang,data) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
site.ID, now.Unix(), s.visitorID(site.ID, ip, ua, now), clip(in.Event, 80), clip(path, 200), ref, browser, osName, device, country, lang, data)
}
// ---------- reading ----------
type topRow struct {
Name string `json:"name"`
Visitors int `json:"visitors"`
Views int `json:"views"` // page views, or the number of times for an event
}
type seriesPoint struct {
Time int64 `json:"t"`
Views int `json:"views"`
Visitors int `json:"visitors"`
}
type siteStats struct {
Range string `json:"range"`
Bucket string `json:"bucket"` // "hour" or "day"
Visitors int `json:"visitors"`
Views int `json:"views"`
Visits int `json:"visits"`
BounceRate float64 `json:"bounceRate"` // percent of visits with one page view; -1 if no visits
Online int `json:"online"` // distinct visitors in the last 5 minutes
Series []seriesPoint `json:"series"`
Pages []topRow `json:"pages"`
Referrers []topRow `json:"referrers"`
Browsers []topRow `json:"browsers"`
Systems []topRow `json:"systems"`
Devices []topRow `json:"devices"`
Countries []topRow `json:"countries"`
Languages []topRow `json:"languages"`
Events []topRow `json:"events"`
}
var ranges = map[string]time.Duration{"24h": 24 * time.Hour, "7d": 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour, "90d": 90 * 24 * time.Hour}
// visitGap is how long a visitor can be quiet before their next page view starts a new visit.
const visitGap = 30 * 60
func (s *Server) websiteStats(id, rng string) (*siteStats, error) {
span, ok := ranges[rng]
if !ok {
rng, span = "7d", ranges["7d"]
}
now := s.now()
from := now.Add(-span).Unix()
out := &siteStats{Range: rng, Bucket: "day", BounceRate: -1, Series: []seriesPoint{}}
step := int64(86400)
if rng == "24h" {
out.Bucket, step = "hour", 3600
}
db := s.st.db
db.QueryRow(`SELECT count(DISTINCT visitor), count(*) FROM hits WHERE website=? AND time>=? AND event=''`, id, from).Scan(&out.Visitors, &out.Views)
db.QueryRow(`SELECT count(DISTINCT visitor) FROM hits WHERE website=? AND time>=?`, id, now.Add(-5*time.Minute).Unix()).Scan(&out.Online)
// Visits and bounces: a visitor's page views, split wherever they were quiet for half an hour.
if rows, err := db.Query(`SELECT visitor,time FROM hits WHERE website=? AND time>=? AND event='' ORDER BY visitor,time`, id, from); err == nil {
var prev string
var prevT int64
views, bounces := 0, 0
closeVisit := func() {
if views > 0 {
out.Visits++
}
if views == 1 {
bounces++
}
}
for rows.Next() {
var v string
var t int64
if rows.Scan(&v, &t) != nil {
continue
}
if v != prev || t-prevT > visitGap {
closeVisit()
views = 0
}
views++
prev, prevT = v, t
}
closeVisit()
rows.Close()
if out.Visits > 0 {
out.BounceRate = float64(bounces) * 100 / float64(out.Visits)
}
}
// The chart: one point per hour or UTC day, empty ones included so the line has no gaps.
buckets := map[int64]seriesPoint{}
if rows, err := db.Query(`SELECT (time/?)*?, count(*), count(DISTINCT visitor) FROM hits WHERE website=? AND time>=? AND event='' GROUP BY 1`, step, step, id, from); err == nil {
for rows.Next() {
var p seriesPoint
if rows.Scan(&p.Time, &p.Views, &p.Visitors) == nil {
buckets[p.Time] = p
}
}
rows.Close()
}
for t := (from / step) * step; t <= now.Unix(); t += step {
p := buckets[t]
p.Time = t
out.Series = append(out.Series, p)
}
top := func(col string, events bool, empty string) []topRow {
cond := "event=''"
sel := col
if events {
cond, sel = "event!=''", "event"
}
rows, err := db.Query(`SELECT `+sel+`, count(DISTINCT visitor), count(*) FROM hits WHERE website=? AND time>=? AND `+cond+
` GROUP BY 1 ORDER BY 3 DESC, 1 LIMIT 10`, id, from)
list := []topRow{}
if err != nil {
return list
}
defer rows.Close()
for rows.Next() {
var r topRow
if rows.Scan(&r.Name, &r.Visitors, &r.Views) == nil {
if r.Name == "" {
r.Name = empty
}
list = append(list, r)
}
}
return list
}
out.Pages = top("path", false, "/")
out.Referrers = top("referrer", false, "Direct")
out.Browsers = top("browser", false, "Other")
out.Systems = top("os", false, "Other")
out.Devices = top("device", false, "Other")
out.Countries = top("country", false, "Unknown")
out.Languages = top("lang", false, "Unknown")
out.Events = top("", true, "")
sort.SliceStable(out.Series, func(i, j int) bool { return out.Series[i].Time < out.Series[j].Time })
return out, nil
}
func (s *Server) websiteStatsHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var site Website
if s.st.get("website", id, &site) != nil {
writeJSON(w, 404, map[string]string{"error": "No such website."})
return
}
st, _ := s.websiteStats(id, r.URL.Query().Get("range"))
writeJSON(w, 200, st)
}
// websiteSummary gives the list page a number for each site: visitors and page views in the last day.
func (s *Server) websiteSummary(w http.ResponseWriter, r *http.Request) {
out := map[string]map[string]int{}
since := s.now().Add(-24 * time.Hour).Unix()
rows, err := s.st.db.Query(`SELECT website, count(DISTINCT visitor), count(*) FROM hits WHERE time>=? AND event='' GROUP BY website`, since)
if err == nil {
defer rows.Close()
for rows.Next() {
var id string
var v, p int
if rows.Scan(&id, &v, &p) == nil {
out[id] = map[string]int{"visitors": v, "views": p}
}
}
}
writeJSON(w, 200, out)
}