chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package pagination
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCursorRoundTripAndTamperProtection(t *testing.T) {
|
||||
codec, err := NewCodec([]byte("01234567890123456789012345678901"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded, err := codec.Encode("2026-08-23T12:00:00Z", "item-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := codec.Decode(encoded)
|
||||
if err != nil || decoded.ID != "item-1" {
|
||||
t.Fatalf("decoded = %#v, err = %v", decoded, err)
|
||||
}
|
||||
tampered := encoded[:len(encoded)-1] + "A"
|
||||
if _, err := codec.Decode(tampered); !errors.Is(err, ErrInvalidCursor) {
|
||||
t.Fatalf("tampered cursor error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLimit(t *testing.T) {
|
||||
if value, err := ParseLimit(""); err != nil || value != DefaultLimit {
|
||||
t.Fatalf("default limit = %d, err = %v", value, err)
|
||||
}
|
||||
for _, invalid := range []string{"0", "101", "abc", "-1"} {
|
||||
if _, err := ParseLimit(invalid); err == nil {
|
||||
t.Fatalf("expected %q to be rejected", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user