package visitor import ( "crypto/rand" "crypto/sha256" "encoding/hex" "net" "net/http" "strings" "sync" "time" ) type Salter interface { SaltFor(day string) (string, error) } // Salter that persists the day's salt in a key/value store. The store interface // is minimal so tests can pass a fake. type storeSalter interface { MetaGet(key string) (string, error) MetaSet(key, value string) error } type DailySalter struct { mu sync.Mutex store storeSalter cache map[string]string } func NewDailySalter(s storeSalter) *DailySalter { return &DailySalter{store: s, cache: map[string]string{}} } func (d *DailySalter) SaltFor(day string) (string, error) { d.mu.Lock() defer d.mu.Unlock() if v, ok := d.cache[day]; ok { return v, nil } key := "salt:" + day existing, err := d.store.MetaGet(key) if err != nil { return "", err } if existing != "" { d.cache[day] = existing return existing, nil } buf := make([]byte, 16) if _, err := rand.Read(buf); err != nil { return "", err } salt := hex.EncodeToString(buf) if err := d.store.MetaSet(key, salt); err != nil { return "", err } d.cache[day] = salt return salt, nil } // Hash produces a 16-char hex fingerprint stable within a day. func Hash(ip, ua, salt string) string { h := sha256.Sum256([]byte(ip + "|" + ua + "|" + salt)) return hex.EncodeToString(h[:])[:16] } func ClientIP(r *http.Request) string { if xf := r.Header.Get("X-Forwarded-For"); xf != "" { parts := strings.Split(xf, ",") return strings.TrimSpace(parts[0]) } if xr := r.Header.Get("X-Real-IP"); xr != "" { return strings.TrimSpace(xr) } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } func Today() string { return time.Now().Format("2006-01-02") }