Files
Pete/internal/web/metrics.go
prosolis e65ffb1373 Add server-side web usage metrics with !petestats command
Track per-page/per-channel view counts and a privacy-preserving daily
unique-visitor estimate (salted IP+UA hash, salt rotated daily and never
persisted). No third-party analytics, no JS beacon. Surfaced via the
admin-gated !petestats Matrix command (named to avoid an existing !stats
bot in the rooms).
2026-06-22 01:54:34 -07:00

94 lines
2.6 KiB
Go

package web
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"log/slog"
"net"
"net/http"
"strings"
"time"
"pete/internal/storage"
)
// track records one page view plus a privacy-preserving unique-visitor token
// for the given coarse label ("home", a channel slug, …). It is deliberately
// cheap and best-effort: the visitor token is derived synchronously (so it sees
// the live request) but the DB writes are fired in the background so page
// rendering never waits on them.
func (s *Server) track(r *http.Request, label string) {
token := s.visitorToken(r)
go func() {
storage.RecordPageView(label)
if token != "" {
storage.RecordVisitor(token)
}
}()
}
// visitorToken returns an irreversible per-day token for the requester: a
// SHA-256 of (daily salt || client IP || User-Agent), truncated. Because the
// salt rotates each UTC day and is never stored, tokens cannot be reversed back
// to an IP or linked across days. Returns "" if no client IP can be determined.
func (s *Server) visitorToken(r *http.Request) string {
ip := clientIP(r)
if ip == "" {
return ""
}
salt := s.dailySalt()
h := sha256.New()
h.Write(salt[:])
h.Write([]byte(ip))
h.Write([]byte{0})
h.Write([]byte(r.UserAgent()))
return hex.EncodeToString(h.Sum(nil)[:16])
}
// dailySalt returns the current day's salt, generating a fresh random one
// whenever the UTC day rolls over. The previous salt is discarded, which is
// what makes cross-day correlation impossible.
func (s *Server) dailySalt() [16]byte {
day := time.Now().UTC().Unix() / 86400
s.metricsMu.Lock()
defer s.metricsMu.Unlock()
if day != s.saltDay || isZero(s.salt) {
if _, err := rand.Read(s.salt[:]); err != nil {
// crypto/rand should never fail; if it does, fall back to a
// day-derived constant so we still dedup within the day.
slog.Error("web: salt generation failed; using weak fallback", "err", err)
for i := range s.salt {
s.salt[i] = byte(day >> (uint(i%8) * 8))
}
}
s.saltDay = day
}
return s.salt
}
func isZero(b [16]byte) bool {
for _, x := range b {
if x != 0 {
return false
}
}
return true
}
// clientIP extracts the requester's IP, honoring the leftmost X-Forwarded-For
// entry when present (Pete runs behind a reverse proxy) and otherwise falling
// back to RemoteAddr. Only used to derive a one-way hash, never stored.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if first := strings.TrimSpace(strings.Split(xff, ",")[0]); first != "" {
return first
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}