192 lines
6.0 KiB
Go
192 lines
6.0 KiB
Go
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"
|
|
}
|