199 lines
7.0 KiB
Go
199 lines
7.0 KiB
Go
package userstore
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"galaxy/user/internal/domain/common"
|
|
"galaxy/user/internal/domain/entitlement"
|
|
"galaxy/user/internal/domain/policy"
|
|
"galaxy/user/internal/ports"
|
|
)
|
|
|
|
// errPageTokenFiltersMismatch reports that a supplied page token was created
|
|
// for a different normalised filter set. Callers translate it to
|
|
// ports.ErrInvalidPageToken on the boundary.
|
|
var errPageTokenFiltersMismatch = errors.New("page token filters do not match current filters")
|
|
|
|
// pageCursor identifies the last (created_at, user_id) tuple visible on the
|
|
// previous listing page. The cursor is paired with a normalised filter
|
|
// fingerprint so the token cannot be replayed across a different filter set.
|
|
type pageCursor struct {
|
|
CreatedAt time.Time
|
|
UserID common.UserID
|
|
}
|
|
|
|
func (cursor pageCursor) Validate() error {
|
|
if err := common.ValidateTimestamp("page cursor created at", cursor.CreatedAt); err != nil {
|
|
return err
|
|
}
|
|
if err := cursor.UserID.Validate(); err != nil {
|
|
return fmt.Errorf("page cursor user id: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// userListFilters mirrors ports.UserListFilters but excludes the fields that
|
|
// only the service layer enforces (display_name match, user_name) so token
|
|
// replay across a UI re-render that toggles a UI-only filter does not
|
|
// invalidate the cursor.
|
|
type userListFilters struct {
|
|
PaidState entitlement.PaidState
|
|
PaidExpiresBefore *time.Time
|
|
PaidExpiresAfter *time.Time
|
|
DeclaredCountry common.CountryCode
|
|
SanctionCode policy.SanctionCode
|
|
LimitCode policy.LimitCode
|
|
CanLogin *bool
|
|
CanCreatePrivateGame *bool
|
|
CanJoinGame *bool
|
|
}
|
|
|
|
// userListFiltersFromPorts copies the listing-stable subset of port-level
|
|
// filters into the form embedded into the page token fingerprint.
|
|
func userListFiltersFromPorts(filters ports.UserListFilters) userListFilters {
|
|
return userListFilters{
|
|
PaidState: filters.PaidState,
|
|
PaidExpiresBefore: filters.PaidExpiresBefore,
|
|
PaidExpiresAfter: filters.PaidExpiresAfter,
|
|
DeclaredCountry: filters.DeclaredCountry,
|
|
SanctionCode: filters.SanctionCode,
|
|
LimitCode: filters.LimitCode,
|
|
CanLogin: filters.CanLogin,
|
|
CanCreatePrivateGame: filters.CanCreatePrivateGame,
|
|
CanJoinGame: filters.CanJoinGame,
|
|
}
|
|
}
|
|
|
|
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 + filters into the frozen opaque page token
|
|
// shape used by the trusted admin listing surface. The encoding is identical
|
|
// to the previous Redis implementation so existing public clients can keep
|
|
// using their stored tokens through the migration cut-over.
|
|
func encodePageToken(cursor pageCursor, filters userListFilters) (string, error) {
|
|
if err := cursor.Validate(); err != nil {
|
|
return "", fmt.Errorf("encode page token: %w", err)
|
|
}
|
|
fingerprint, err := normaliseFilters(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 parses raw and verifies the embedded fingerprint matches
|
|
// expected. The token's wire format is preserved across the Redis-to-
|
|
// PostgreSQL adapter swap.
|
|
func decodePageToken(raw string, expected userListFilters) (pageCursor, error) {
|
|
fingerprint, err := normaliseFilters(expected)
|
|
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 normalisedFilterFields `json:"filters"`
|
|
}
|
|
|
|
type normalisedFilterFields 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 normaliseFilters(filters userListFilters) (normalisedFilterFields, error) {
|
|
if err := filters.Validate(); err != nil {
|
|
return normalisedFilterFields{}, err
|
|
}
|
|
return normalisedFilterFields{
|
|
PaidState: string(filters.PaidState),
|
|
PaidExpiresBeforeUTC: formatOptionalUTC(filters.PaidExpiresBefore),
|
|
PaidExpiresAfterUTC: formatOptionalUTC(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 formatOptionalUTC(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"
|
|
}
|