package store import ( "crypto/rand" "encoding/base64" "fmt" "sync" "time" ) // RedirectTrack represents a single redirect attempt tracking entry type RedirectTrack struct { IP string // Client IP (X-Forwarded-For aware) UserAgent string // Full User-Agent string for debugging Path string // Request path (without query params) Attempts int // Number of redirect attempts FirstSeen time.Time // When first redirect was tracked ExpiresAt time.Time // When to clean up this entry } type Store struct { sessions sync.Map // key: string, value: *RegistrationSession redirectTracks sync.Map // key: string, value: *RedirectTrack cleanup *time.Ticker } func NewStore() *Store { s := &Store{ cleanup: time.NewTicker(1 * time.Minute), } // Background cleanup of expired sessions go func() { for range s.cleanup.C { s.cleanupExpired() } }() return s } func (s *Store) Delete(id string) { s.sessions.Delete(id) } func (s *Store) cleanupExpired() { now := time.Now() // Clean up expired registration sessions s.sessions.Range(func(key, value any) bool { session := value.(*RegistrationSession) if now.After(session.ExpiresAt) { s.sessions.Delete(key) } return true }) // Clean up expired redirect tracks s.redirectTracks.Range(func(key, value any) bool { track := value.(*RedirectTrack) if now.After(track.ExpiresAt) { s.redirectTracks.Delete(key) } return true }) } func generateID() string { b := make([]byte, 32) rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) } // redirectKey generates a unique key for tracking redirects // Uses IP + first 100 chars of UA + path as key (not hashed for debugging) func redirectKey(ip, userAgent, path string) string { ua := userAgent if len(ua) > 100 { ua = ua[:100] } return fmt.Sprintf("%s:%s:%s", ip, ua, path) }