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).
94 lines
2.6 KiB
Go
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
|
|
}
|