main pico / pkg / shared / router / analytics.go
Antonio Mika  ·  2026-10-11
  1package router
  2
  3import (
  4	"crypto/hmac"
  5	"crypto/sha256"
  6	"encoding/hex"
  7	"errors"
  8	"fmt"
  9	"net"
 10	"net/url"
 11	"strings"
 12
 13	"github.com/picosh/pico/pkg/db"
 14	"github.com/simplesurance/go-ip-anonymizer/ipanonymizer"
 15	"github.com/x-way/crawlerdetect"
 16)
 17
 18var internalCrawlers *crawlerdetect.CrawlerDetect
 19
 20func init() {
 21	internalCrawlers = crawlerdetect.New()
 22	internalCrawlers.SetCrawlers([]string{
 23		`^Azure Traffic Manager Endpoint Monitor$`,
 24		`^Blackbox Exporter\/`,
 25		`^Prometheus\/`,
 26	})
 27}
 28
 29func HmacString(secret, data string) string {
 30	hmacer := hmac.New(sha256.New, []byte(secret))
 31	hmacer.Write([]byte(data))
 32	dataHmac := hmacer.Sum(nil)
 33	return hex.EncodeToString(dataHmac)
 34}
 35
 36func trackableUserAgent(agent string) error {
 37	// dont store requests from bots
 38	if crawlerdetect.IsCrawler(agent) || internalCrawlers.IsCrawler(agent) {
 39		return fmt.Errorf(
 40			"request is likely from a bot (User-Agent: %s)",
 41			CleanUserAgent(agent),
 42		)
 43	}
 44	return nil
 45}
 46
 47func cleanIpAddress(ip string) (string, error) {
 48	host, _, err := net.SplitHostPort(ip)
 49	if err != nil {
 50		host = ip
 51	}
 52	// /24 IPv4 subnet mask
 53	// /64 IPv6 subnet mask
 54	anonymizer := ipanonymizer.NewWithMask(
 55		net.CIDRMask(24, 32),
 56		net.CIDRMask(64, 128),
 57	)
 58	anonIp, err := anonymizer.IPString(host)
 59	return anonIp, err
 60}
 61
 62func cleanUrl(orig string) (string, string) {
 63	u, err := url.Parse(orig)
 64	if err != nil {
 65		return "", ""
 66	}
 67	return u.Host, u.Path
 68}
 69
 70func CleanUserAgent(ua string) string {
 71	// truncate user-agent because http headers have no text limit
 72	if len(ua) > 1000 {
 73		return ua[:1000]
 74	}
 75	return strings.TrimSpace(ua)
 76}
 77
 78func filterIp(host string) (string, error) {
 79	if host == "" {
 80		return "", nil
 81	}
 82	addr := net.ParseIP(host)
 83	if addr != nil {
 84		return "", fmt.Errorf("host is an ip")
 85	}
 86	return host, nil
 87}
 88
 89func CleanReferer(raw string) (string, error) {
 90	ref := raw
 91	if ref == "" {
 92		return "", nil
 93	}
 94	// referer sometimes dont include scheme but we need it
 95	if !strings.HasPrefix(ref, "http") {
 96		ref = "https://" + ref
 97	}
 98	// we only want to store host for security reasons
 99	// https://developer.mozilla.org/en-US/docs/Web/Security/Referer_header:_privacy_and_security_concerns
100	u, err := url.Parse(ref)
101	if err != nil {
102		return "", err
103	}
104	hostname := u.Hostname()
105	hostname, _ = filterIp(hostname)
106	hostname = strings.TrimSpace(strings.ToLower(hostname))
107	return hostname, err
108}
109
110func CleanHost(raw string) (string, error) {
111	prep := strings.TrimSpace(strings.ToLower(raw))
112	if prep == "" {
113		return "", fmt.Errorf("host is blank")
114	}
115	// hosts dont usually include scheme but we need it
116	if !strings.HasPrefix(prep, "http") {
117		prep = "https://" + prep
118	}
119	// no clue why but our prod data contains periods
120	prep = strings.Trim(prep, ".")
121	// we only want to store host for security reasons
122	// https://developer.mozilla.org/en-US/docs/Web/Security/Referer_header:_privacy_and_security_concerns
123	u, err := url.Parse(prep)
124	if err != nil {
125		return raw, err
126	}
127	host := u.Hostname()
128	host, err = filterIp(host)
129	return host, err
130}
131
132var ErrAnalyticsDisabled = errors.New("owner does not have site analytics enabled")
133
134func AnalyticsVisitFromVisit(visit *db.AnalyticsVisits, dbpool interface {
135	FindFeature(userID string, feature string) (*db.FeatureFlag, error)
136}, secret string) error {
137	if visit.PostID != "" {
138		if !db.HasPlusOrSpace(dbpool, visit.UserID, "prose") {
139			return ErrAnalyticsDisabled
140		}
141	} else if !db.HasFeature(dbpool, visit.UserID, "analytics") {
142		return ErrAnalyticsDisabled
143	}
144
145	err := trackableUserAgent(visit.UserAgent)
146	if err != nil {
147		return err
148	}
149
150	ipAddress, err := cleanIpAddress(visit.IpAddress)
151	if err != nil {
152		return err
153	}
154	visit.IpAddress = HmacString(secret, ipAddress)
155	_, path := cleanUrl(visit.Path)
156	visit.Path = path
157
158	referer, err := CleanReferer(visit.Referer)
159	if err != nil {
160		return err
161	}
162	visit.Referer = referer
163
164	hostname, err := CleanHost(visit.Host)
165	if err != nil {
166		return err
167	}
168	visit.Host = hostname
169	visit.UserAgent = CleanUserAgent(visit.UserAgent)
170	visit.ContentType = strings.ToLower(visit.ContentType)
171
172	return nil
173}