package pagination import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "strconv" "strings" ) const ( DefaultLimit = 20 MaxLimit = 100 ) var ErrInvalidCursor = errors.New("invalid pagination cursor") type Cursor struct { Version int `json:"v"` SortKey string `json:"sort_key"` ID string `json:"id"` } type Codec struct{ key []byte } func NewCodec(signingKey []byte) (*Codec, error) { if len(signingKey) < 32 { return nil, errors.New("cursor signing key must contain at least 32 bytes") } return &Codec{key: append([]byte(nil), signingKey...)}, nil } func (c *Codec) Encode(sortKey, id string) (string, error) { if strings.TrimSpace(sortKey) == "" || strings.TrimSpace(id) == "" { return "", errors.New("cursor sort key and id are required") } payload, err := json.Marshal(Cursor{Version: 1, SortKey: sortKey, ID: id}) if err != nil { return "", fmt.Errorf("marshal cursor: %w", err) } encoded := base64.RawURLEncoding.EncodeToString(payload) return encoded + "." + base64.RawURLEncoding.EncodeToString(c.sign([]byte(encoded))), nil } func (c *Codec) Decode(value string) (Cursor, error) { parts := strings.Split(value, ".") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return Cursor{}, ErrInvalidCursor } signature, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil || !hmac.Equal(signature, c.sign([]byte(parts[0]))) { return Cursor{}, ErrInvalidCursor } payload, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return Cursor{}, ErrInvalidCursor } var cursor Cursor if err := json.Unmarshal(payload, &cursor); err != nil || cursor.Version != 1 || cursor.SortKey == "" || cursor.ID == "" { return Cursor{}, ErrInvalidCursor } return cursor, nil } func ParseLimit(value string) (int, error) { if strings.TrimSpace(value) == "" { return DefaultLimit, nil } limit, err := strconv.Atoi(value) if err != nil || limit <= 0 || limit > MaxLimit { return 0, fmt.Errorf("limit must be an integer between 1 and %d", MaxLimit) } return limit, nil } func (c *Codec) sign(payload []byte) []byte { mac := hmac.New(sha256.New, c.key) _, _ = mac.Write(payload) return mac.Sum(nil) }