package main // Port of lib/access.js: subscription client identity, active-client and // observed-IP windows, and access-log pruning. // // JS Date.parse failures yield NaN and every comparison against NaN is // false; ParseISOMs reports those as ok=false and the filters below exclude // such records, preserving the JS semantics. import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "net/http" "strings" "time" ) // Window constants from lib/access.js, in milliseconds. const ( ClientWindowMs = int64(24 * 60 * 60 * 1000) LogRetentionMs = int64(30 * 24 * 60 * 60 * 1000) ) // ClientIdentityResult is the JS clientIdentity return shape // { userAgent, kind, key }. type ClientIdentityResult struct { UserAgent string `json:"userAgent"` Kind string `json:"kind"` Key string `json:"key"` } // truncRunes truncates to at most n characters. JS slice() counts UTF-16 // code units; counting runes matches for all BMP text. func truncRunes(s string, n int) string { runes := []rune(s) if len(runes) > n { runes = runes[:n] } return string(runes) } // ClientIdentity mirrors JS clientIdentity(headers, secret): the declared // identity is x-device-id, falling back to x-client-id, trimmed and cut to // 128 chars; the user agent is trimmed and cut to 200 chars; kind is // "header" when a declared identity exists, else "user-agent" when a user // agent exists, else "unknown". Key is HMAC-SHA256(secret, "kind:identity") // as hex. func ClientIdentity(headers http.Header, secret string) ClientIdentityResult { declared := headers.Get("X-Device-Id") if declared == "" { declared = headers.Get("X-Client-Id") } declared = truncRunes(strings.TrimSpace(declared), 128) userAgent := truncRunes(strings.TrimSpace(headers.Get("User-Agent")), 200) kind := "unknown" if declared != "" { kind = "header" } else if userAgent != "" { kind = "user-agent" } identity := declared if identity == "" { identity = userAgent } if identity == "" { identity = "unknown" } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(kind + ":" + identity)) return ClientIdentityResult{ UserAgent: userAgent, Kind: kind, Key: hex.EncodeToString(mac.Sum(nil)), } } func observationTtlMinutes(d *Data) float64 { ttl := d.Settings.ObservationTtlMinutes if ttl == 0 { // JS: settings.observationTtlMinutes || 10 — 0 (and NaN) fall back. ttl = 10 } return ttl } // ActiveClients returns the clientKeys of the customer's subscription // clients seen within the last 24h, in stored order. func ActiveClients(d *Data, customerID string, now time.Time) []string { cutoff := now.UnixMilli() - ClientWindowMs out := []string{} for _, item := range d.SubscriptionClients { if item.CustomerID != customerID { continue } ms, ok := ParseISOMs(item.LastSeenAt) if ok && ms > cutoff { out = append(out, item.ClientKey) } } return out } // ObservedIPs returns the customer's observations fresher than the // configured observation TTL (settings.observationTtlMinutes || 10). func ObservedIPs(d *Data, customerID string, now time.Time) []*Observation { cutoff := now.UnixMilli() - int64(observationTtlMinutes(d)*60000) out := []*Observation{} for _, item := range d.Observations { if item.CustomerID != customerID { continue } ms, ok := ParseISOMs(item.LastSeenAt) if ok && ms > cutoff { out = append(out, item) } } return out } // PruneAccess mirrors JS pruneAccess: subscriptionAccess keeps the last // 10000 entries fresher than 30 days, subscriptionClients keeps entries // fresher than 24h, observations keep entries fresher than the TTL. func PruneAccess(d *Data, now time.Time) { nowMs := now.UnixMilli() retentionCutoff := nowMs - LogRetentionMs access := []*SubscriptionAccess{} for _, item := range d.SubscriptionAccess { if ms, ok := ParseISOMs(item.At); ok && ms > retentionCutoff { access = append(access, item) } } if len(access) > 10000 { // JS: .slice(-10000) access = access[len(access)-10000:] } d.SubscriptionAccess = access clientCutoff := nowMs - ClientWindowMs clients := []*SubscriptionClient{} for _, item := range d.SubscriptionClients { if ms, ok := ParseISOMs(item.LastSeenAt); ok && ms > clientCutoff { clients = append(clients, item) } } d.SubscriptionClients = clients observationCutoff := nowMs - int64(observationTtlMinutes(d)*60000) observations := []*Observation{} for _, item := range d.Observations { if ms, ok := ParseISOMs(item.LastSeenAt); ok && ms > observationCutoff { observations = append(observations, item) } } d.Observations = observations }