84 lines
2.2 KiB
Go
84 lines
2.2 KiB
Go
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)
|
||
|
|
}
|