Files
galaxy-game/user/internal/adapters/postgres/userstore/page_token.go
T
2026-04-26 20:34:39 +02:00

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"
}