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 }