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).
This commit is contained in:
@@ -123,6 +123,7 @@ func (s *Server) base(r *http.Request) pageData {
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
s.track(r, "home")
|
||||
const (
|
||||
justPostedLimit = 6
|
||||
latestLimit = 16
|
||||
@@ -177,6 +178,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channel) {
|
||||
s.track(r, ch.Slug)
|
||||
page := 1
|
||||
if p := r.URL.Query().Get("page"); p != "" {
|
||||
if n, err := strconv.Atoi(p); err == nil && n > 0 {
|
||||
|
||||
93
internal/web/metrics.go
Normal file
93
internal/web/metrics.go
Normal file
@@ -0,0 +1,93 @@
|
||||
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
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pete/internal/config"
|
||||
@@ -56,6 +57,12 @@ type Server struct {
|
||||
srv *http.Server
|
||||
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
||||
auth *Authenticator // nil when sign-in is disabled or unavailable
|
||||
|
||||
// Daily-rotated salt for the privacy-preserving unique-visitor estimate.
|
||||
// Guarded by metricsMu; never persisted (see metrics.go).
|
||||
metricsMu sync.Mutex
|
||||
saltDay int64
|
||||
salt [16]byte
|
||||
}
|
||||
|
||||
// New builds the server. Templates are parsed once at startup. Each page
|
||||
|
||||
Reference in New Issue
Block a user