Files
long-agent adb251e4ad fix: P2 security and correctness issues
P2-10: Change ActivateEmail from GET to POST - token now passed in
request body instead of URL query parameter for better security

P2-11: Change ValidateResetToken from GET to POST - token now passed
in request body instead of URL query parameter to prevent log leakage

P2-12: Note - /uploads static exposure remains (requires architectural
decision about file serving)

P2-13: cursor.Encode() now checks and returns empty string on JSON
marshaling error instead of silently ignoring

P2-14: initDefaultData and ensurePermissions now properly check and
propagate errors from RolePermission creation, and createDefaultPermissions
aggregates errors instead of silently continuing

P2-15: NewJWT now returns (nil, error) on initialization failure
instead of a partially initialized object. All callers updated to handle
the error return.

Backend routes updated:
- POST /auth/activate-email (was GET /activate)
- POST /auth/password/validate (was GET /reset-password)

Frontend updated to match new API endpoints.
2026-04-18 20:48:11 +08:00

87 lines
2.6 KiB
Go

// Package pagination provides cursor-based (keyset) pagination utilities.
//
// Unlike offset-based pagination (OFFSET/LIMIT), cursor pagination uses
// a composite key (typically created_at + id) to locate the "position" in
// the result set, giving O(limit) performance regardless of how deep you page.
package pagination
import (
"encoding/base64"
"encoding/json"
"fmt"
"time"
)
// Cursor represents an opaque position in a sorted result set.
// It is serialized as a URL-safe base64 string for transport.
type Cursor struct {
// LastID is the primary key of the last item on the current page.
LastID int64 `json:"last_id"`
// LastValue is the sort column value of the last item (e.g. created_at).
LastValue time.Time `json:"last_value"`
}
// Encode serializes a Cursor to a URL-safe base64 string suitable for query params.
func (c *Cursor) Encode() string {
if c == nil || c.LastID == 0 {
return ""
}
data, err := json.Marshal(c)
if err != nil {
return ""
}
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data)
}
// Decode parses a base64-encoded cursor string back into a Cursor.
// Returns nil for empty strings (meaning "first page").
func Decode(encoded string) (*Cursor, error) {
if encoded == "" {
return nil, nil
}
data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("invalid cursor encoding: %w", err)
}
var c Cursor
if err := json.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("invalid cursor data: %w", err)
}
return &c, nil
}
// PageResult wraps a paginated response with cursor navigation info.
type PageResult[T any] struct {
Items []T `json:"items"`
Total int64 `json:"total"` // Approximate or exact total (optional for pure cursor mode)
NextCursor string `json:"next_cursor"` // Empty means no more pages
HasMore bool `json:"has_more"`
PageSize int `json:"page_size"`
}
// DefaultPageSize is the default number of items per page.
const DefaultPageSize = 20
// MaxPageSize caps the maximum allowed items per request to prevent abuse.
const MaxPageSize = 100
// ClampPageSize ensures size is within [1, MaxPageSize], falling back to DefaultPageSize.
func ClampPageSize(size int) int {
if size <= 0 {
return DefaultPageSize
}
if size > MaxPageSize {
return MaxPageSize
}
return size
}
// BuildNextCursor creates a cursor from the last item's ID and timestamp.
// Returns empty string if there are no items.
func BuildNextCursor(lastID int64, lastTime time.Time) string {
if lastID == 0 {
return ""
}
return (&Cursor{LastID: lastID, LastValue: lastTime}).Encode()
}