Keep JSON and bounded CSV exports on one canonical predicate, replace replayable offsets with encrypted tenant/filter-bound keyset cursors, and suppress merchant-grain peers under terminal scope. Validate period filters and align OpenAPI and capability specs with runtime behavior.
109 lines
3.4 KiB
Go
109 lines
3.4 KiB
Go
package analytics
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"time"
|
|
)
|
|
|
|
var ErrInvalidCursor = errors.New("analytics: invalid cursor")
|
|
|
|
type detailCursorPosition struct {
|
|
CreatedAt time.Time
|
|
SessionID int64
|
|
}
|
|
|
|
type detailCursorPayload struct {
|
|
Version int `json:"v"`
|
|
Binding string `json:"b"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
SessionID int64 `json:"sessionId"`
|
|
ExpiresAt time.Time `json:"expiresAt"`
|
|
}
|
|
|
|
type detailCursorBinding struct {
|
|
MerchantID int64 `json:"merchantId"`
|
|
PeriodID string `json:"periodId"`
|
|
View string `json:"view"`
|
|
FailureStage string `json:"failureStage"`
|
|
TerminalID string `json:"terminalId"`
|
|
Status string `json:"status"`
|
|
Minimum *int64 `json:"minimum"`
|
|
Maximum *int64 `json:"maximum"`
|
|
}
|
|
|
|
func (s *Service) encodeDetailCursor(merchantID int64, period Period, filter DetailFilter, createdAt time.Time, sessionID int64) (string, error) {
|
|
payload := detailCursorPayload{
|
|
Version: 1,
|
|
Binding: detailCursorBindingDigest(merchantID, period, filter),
|
|
CreatedAt: createdAt.UTC(),
|
|
SessionID: sessionID,
|
|
ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
|
|
}
|
|
encoded, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
aead, err := s.cursorAEAD()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
nonce := make([]byte, aead.NonceSize())
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return "", err
|
|
}
|
|
sealed := aead.Seal(nonce, nonce, encoded, nil)
|
|
return base64.RawURLEncoding.EncodeToString(sealed), nil
|
|
}
|
|
|
|
func (s *Service) decodeDetailCursor(merchantID int64, period Period, filter DetailFilter, cursor string) (detailCursorPosition, error) {
|
|
sealed, err := base64.RawURLEncoding.DecodeString(cursor)
|
|
if err != nil {
|
|
return detailCursorPosition{}, ErrInvalidCursor
|
|
}
|
|
aead, err := s.cursorAEAD()
|
|
if err != nil || len(sealed) < aead.NonceSize() {
|
|
return detailCursorPosition{}, ErrInvalidCursor
|
|
}
|
|
nonce, ciphertext := sealed[:aead.NonceSize()], sealed[aead.NonceSize():]
|
|
payloadBytes, err := aead.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
return detailCursorPosition{}, ErrInvalidCursor
|
|
}
|
|
var payload detailCursorPayload
|
|
if err := json.Unmarshal(payloadBytes, &payload); err != nil || payload.Version != 1 || payload.SessionID <= 0 || payload.CreatedAt.IsZero() || time.Now().UTC().After(payload.ExpiresAt) {
|
|
return detailCursorPosition{}, ErrInvalidCursor
|
|
}
|
|
wantBinding := detailCursorBindingDigest(merchantID, period, filter)
|
|
if payload.Binding != wantBinding {
|
|
return detailCursorPosition{}, ErrInvalidCursor
|
|
}
|
|
return detailCursorPosition{CreatedAt: payload.CreatedAt.UTC(), SessionID: payload.SessionID}, nil
|
|
}
|
|
|
|
func (s *Service) cursorAEAD() (cipher.AEAD, error) {
|
|
key := sha256.Sum256(s.cursorSecret)
|
|
block, err := aes.NewCipher(key[:])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return cipher.NewGCM(block)
|
|
}
|
|
|
|
func detailCursorBindingDigest(merchantID int64, period Period, filter DetailFilter) string {
|
|
binding := detailCursorBinding{
|
|
MerchantID: merchantID, PeriodID: period.PeriodID, View: filter.View,
|
|
FailureStage: filter.FailureStage, TerminalID: filter.TerminalID, Status: filter.Status,
|
|
Minimum: filter.MinAmountRial, Maximum: filter.MaxAmountRial,
|
|
}
|
|
encoded, _ := json.Marshal(binding)
|
|
digest := sha256.Sum256(encoded)
|
|
return base64.RawURLEncoding.EncodeToString(digest[:])
|
|
}
|