package redisstate import ( "encoding/base64" "encoding/json" "errors" "fmt" "time" "galaxy/user/internal/domain/common" "galaxy/user/internal/domain/entitlement" "galaxy/user/internal/domain/policy" ) var ( // ErrPageTokenFiltersMismatch reports that a supplied page token was created // for a different normalized filter set. ErrPageTokenFiltersMismatch = errors.New("page token filters do not match current filters") ) // UserListFilters stores the frozen admin-listing filter set that becomes part // of the opaque page token fingerprint. type UserListFilters struct { // PaidState stores the coarse free-versus-paid filter. PaidState entitlement.PaidState // PaidExpiresBefore stores the optional finite-paid expiry upper bound. PaidExpiresBefore *time.Time // PaidExpiresAfter stores the optional finite-paid expiry lower bound. PaidExpiresAfter *time.Time // DeclaredCountry stores the optional declared-country filter. DeclaredCountry common.CountryCode // SanctionCode stores the optional active-sanction filter. SanctionCode policy.SanctionCode // LimitCode stores the optional active-limit filter. LimitCode policy.LimitCode // CanLogin stores the optional login-eligibility filter. CanLogin *bool // CanCreatePrivateGame stores the optional private-game-create eligibility // filter. CanCreatePrivateGame *bool // CanJoinGame stores the optional join-game eligibility filter. CanJoinGame *bool } // Validate reports whether UserListFilters is structurally valid. func (filters UserListFilters) Validate() error { if !filters.PaidState.IsKnown() { return fmt.Errorf("paid state %q is unsupported", filters.PaidState) } if filters.PaidExpiresBefore != nil && filters.PaidExpiresBefore.IsZero() { return fmt.Errorf("paid expires before must not be zero") } if filters.PaidExpiresAfter != nil && filters.PaidExpiresAfter.IsZero() { return fmt.Errorf("paid expires after must not be zero") } if !filters.DeclaredCountry.IsZero() { if err := filters.DeclaredCountry.Validate(); err != nil { return fmt.Errorf("declared country: %w", err) } } if filters.SanctionCode != "" && !filters.SanctionCode.IsKnown() { return fmt.Errorf("sanction code %q is unsupported", filters.SanctionCode) } if filters.LimitCode != "" && !filters.LimitCode.IsKnown() { return fmt.Errorf("limit code %q is unsupported", filters.LimitCode) } return nil } // EncodePageToken encodes cursor and filters into the frozen opaque page token // format. func EncodePageToken(cursor PageCursor, filters UserListFilters) (string, error) { if err := cursor.Validate(); err != nil { return "", fmt.Errorf("encode page token: %w", err) } fingerprint, err := normalizeFilters(filters) if err != nil { return "", fmt.Errorf("encode page token: %w", err) } payload, err := json.Marshal(pageTokenPayload{ CreatedAt: cursor.CreatedAt.UTC().Format(time.RFC3339Nano), UserID: cursor.UserID.String(), Filters: fingerprint, }) if err != nil { return "", fmt.Errorf("encode page token: %w", err) } return base64.RawURLEncoding.EncodeToString(payload), nil } // DecodePageToken decodes raw into the frozen page cursor and verifies that // the embedded normalized filter set matches expectedFilters. func DecodePageToken(raw string, expectedFilters UserListFilters) (PageCursor, error) { fingerprint, err := normalizeFilters(expectedFilters) if err != nil { return PageCursor{}, fmt.Errorf("decode page token: %w", err) } payload, err := base64.RawURLEncoding.DecodeString(raw) if err != nil { return PageCursor{}, fmt.Errorf("decode page token: %w", err) } var token pageTokenPayload if err := json.Unmarshal(payload, &token); err != nil { return PageCursor{}, fmt.Errorf("decode page token: %w", err) } if token.Filters != fingerprint { return PageCursor{}, ErrPageTokenFiltersMismatch } createdAt, err := time.Parse(time.RFC3339Nano, token.CreatedAt) if err != nil { return PageCursor{}, fmt.Errorf("decode page token: parse created_at: %w", err) } cursor := PageCursor{ CreatedAt: createdAt.UTC(), UserID: common.UserID(token.UserID), } if err := cursor.Validate(); err != nil { return PageCursor{}, fmt.Errorf("decode page token: %w", err) } return cursor, nil } type pageTokenPayload struct { CreatedAt string `json:"created_at"` UserID string `json:"user_id"` Filters normalizedFilterPayload `json:"filters"` } type normalizedFilterPayload struct { PaidState string `json:"paid_state,omitempty"` PaidExpiresBeforeUTC string `json:"paid_expires_before_utc,omitempty"` PaidExpiresAfterUTC string `json:"paid_expires_after_utc,omitempty"` DeclaredCountry string `json:"declared_country,omitempty"` SanctionCode string `json:"sanction_code,omitempty"` LimitCode string `json:"limit_code,omitempty"` CanLogin string `json:"can_login,omitempty"` CanCreatePrivateGame string `json:"can_create_private_game,omitempty"` CanJoinGame string `json:"can_join_game,omitempty"` } func normalizeFilters(filters UserListFilters) (normalizedFilterPayload, error) { if err := filters.Validate(); err != nil { return normalizedFilterPayload{}, err } return normalizedFilterPayload{ PaidState: string(filters.PaidState), PaidExpiresBeforeUTC: formatOptionalTime(filters.PaidExpiresBefore), PaidExpiresAfterUTC: formatOptionalTime(filters.PaidExpiresAfter), DeclaredCountry: filters.DeclaredCountry.String(), SanctionCode: string(filters.SanctionCode), LimitCode: string(filters.LimitCode), CanLogin: formatOptionalBool(filters.CanLogin), CanCreatePrivateGame: formatOptionalBool(filters.CanCreatePrivateGame), CanJoinGame: formatOptionalBool(filters.CanJoinGame), }, nil } func formatOptionalTime(value *time.Time) string { if value == nil { return "" } return value.UTC().Format(time.RFC3339Nano) } func formatOptionalBool(value *bool) string { if value == nil { return "" } if *value { return "true" } return "false" }