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[:]) }