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}