From 7045018be9debc9eb9a42e9d36c2dec8e366a749 Mon Sep 17 00:00:00 2001 From: Buddhi Thilakshana <87850797+xbuddhi@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:43:04 +0530 Subject: [PATCH] feat: add analytics API endpoints and filter empty telebot errors (#58) Add HMAC-authenticated analytics API for transaction queries and user history. Filter empty/ghost telebot errors from being sent to the TG error log group, and improve LNbits error handling to always return meaningful error details. --- .gitignore | 9 +- config.yaml.example | 17 +- internal/api/analytics.go | 620 +++++++++++++++++++++++++++++++++++++ internal/api/middleware.go | 69 +++++ internal/config.go | 50 ++- internal/lnbits/lnbits.go | 10 +- main.go | 9 + 7 files changed, 776 insertions(+), 8 deletions(-) create mode 100644 internal/api/analytics.go diff --git a/.gitignore b/.gitignore index 3d1df7ce..246754b2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,10 @@ LightningTipBot LightningTipBot.exe BitcoinDeepaBot test_pay_api.sh -ANALYTICS_API.md -.gitignore -ANALYTICS_QUICKSTART.md + +.DS_Store .claude/settings.local.json -analytics_requirements.txt analytics_export.py +analytics_requirements.txt +ANALYTICS_API.md +ANALYTICS_QUICKSTART.md diff --git a/config.yaml.example b/config.yaml.example index ff8cf6a4..dcbb2d50 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -36,8 +36,23 @@ generate: nostr: private_key: "hex private key here" -# API Send Configuration +# API Configuration api: + # Analytics API - HMAC authenticated endpoints for data export + # Generate secrets with: openssl rand -hex 32 + analytics: + enabled: true + timestamp_tolerance: 300 # seconds (5 minutes) + api_keys: + data-team: + name: "Data Team" + hmac_secret: "your-analytics-hmac-secret-here" + # Add more keys for different consumers: + # dashboard: + # name: "Internal Dashboard" + # hmac_secret: "another-secure-secret-here" + + # Send API - wallet-based HMAC authenticated endpoints send: enabled: true internal_network: "10.0.0.0/24" diff --git a/internal/api/analytics.go b/internal/api/analytics.go new file mode 100644 index 00000000..7c9483d2 --- /dev/null +++ b/internal/api/analytics.go @@ -0,0 +1,620 @@ +package api + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/telegram" + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" +) + +const ( + // maxAnalyticsLimit caps the maximum number of records per request to prevent memory exhaustion + maxAnalyticsLimit = 10000 + // maxAnalyticsOffset caps the offset to prevent abuse + maxAnalyticsOffset = 100000 + // minValidTimestamp is 2009-01-03 (Bitcoin genesis block) - no valid data before this + minValidTimestamp int64 = 1230940800 + // maxValidTimestamp is 2100-01-01 - reasonable upper bound + maxValidTimestamp int64 = 4102444800 +) + +// TransactionAnalyticsResponse represents the analytics data response +type TransactionAnalyticsResponse struct { + Status string `json:"status"` + ExternalPayments []ExternalPaymentData `json:"external_payments,omitempty"` + InternalTxs []InternalTransactionData `json:"internal_transactions,omitempty"` + Summary TransactionSummary `json:"summary"` + Filters map[string]string `json:"filters_applied"` +} + +// ExternalPaymentData represents external LNbits payment data +type ExternalPaymentData struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + CheckingID string `json:"checking_id"` + Pending bool `json:"pending"` + Amount int64 `json:"amount_msats"` + AmountSats int64 `json:"amount_sats"` + Fee int64 `json:"fee_msats"` + FeeSats int64 `json:"fee_sats"` + Memo string `json:"memo"` + Time int `json:"time"` + Timestamp string `json:"timestamp"` + PaymentType string `json:"payment_type"` // "incoming" or "outgoing" + Bolt11 string `json:"bolt11,omitempty"` + PaymentHash string `json:"payment_hash"` + WalletID string `json:"wallet_id"` +} + +// InternalTransactionData represents internal bot transactions +type InternalTransactionData struct { + ID uint `json:"id"` + Time string `json:"time"` + FromID int64 `json:"from_id"` + ToID int64 `json:"to_id"` + FromUser string `json:"from_user"` + ToUser string `json:"to_user"` + Type string `json:"type"` + Amount int64 `json:"amount_sats"` + ChatID int64 `json:"chat_id,omitempty"` + ChatName string `json:"chat_name,omitempty"` + Memo string `json:"memo"` + Success bool `json:"success"` +} + +// TransactionSummary provides aggregate statistics +type TransactionSummary struct { + TotalExternalCount int `json:"total_external_count"` + TotalInternalCount int `json:"total_internal_count"` + ExternalIncoming int64 `json:"external_incoming_sats"` + ExternalOutgoing int64 `json:"external_outgoing_sats"` + InternalVolume int64 `json:"internal_volume_sats"` + UniqueUsers int `json:"unique_users"` +} + +// GetTransactionAnalytics retrieves transaction data for analytics +// Endpoint: GET /api/v1/analytics/transactions +// Query Parameters: +// - user_id: Filter by specific user Telegram ID +// - username: Filter by username (without @) +// - start_date: Start date (YYYY-MM-DD or Unix timestamp) +// - end_date: End date (YYYY-MM-DD or Unix timestamp) +// - payment_type: Filter external payments by type (incoming/outgoing/all) +// - include_external: Include external LNbits payments (true/false, default: true) +// - include_internal: Include internal bot transactions (true/false, default: true) +// - limit: Maximum number of transactions per type (default: 1000) +// - offset: Number of transactions to skip for pagination (default: 0) +// - format: Response format - "json" (default) or "csv" +func (s Service) GetTransactionAnalytics(w http.ResponseWriter, r *http.Request) { + // Parse query parameters + params := r.URL.Query() + + userIDStr := params.Get("user_id") + username := params.Get("username") + startDateStr := params.Get("start_date") + endDateStr := params.Get("end_date") + paymentTypeFilter := params.Get("payment_type") + includeExternal := params.Get("include_external") != "false" + includeInternal := params.Get("include_internal") != "false" + limitStr := params.Get("limit") + offsetStr := params.Get("offset") + outputFormat := params.Get("format") + + // Set default limit with max cap + limit := 1000 + if limitStr != "" { + if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { + limit = parsedLimit + } + } + if limit > maxAnalyticsLimit { + limit = maxAnalyticsLimit + } + + // Set default offset with max cap + offset := 0 + if offsetStr != "" { + if parsedOffset, err := strconv.Atoi(offsetStr); err == nil && parsedOffset >= 0 { + offset = parsedOffset + } + } + if offset > maxAnalyticsOffset { + offset = maxAnalyticsOffset + } + + // Parse dates + var startDate, endDate time.Time + var err error + + if startDateStr != "" { + startDate, err = parseDate(startDateStr) + if err != nil { + RespondError(w, "Invalid start_date format. Use YYYY-MM-DD or Unix timestamp") + return + } + } + + if endDateStr != "" { + endDate, err = parseDate(endDateStr) + if err != nil { + RespondError(w, "Invalid end_date format. Use YYYY-MM-DD or Unix timestamp") + return + } + } + + response := TransactionAnalyticsResponse{ + Status: StatusOk, + Filters: make(map[string]string), + } + + // Track applied filters + if userIDStr != "" { + response.Filters["user_id"] = userIDStr + } + if username != "" { + response.Filters["username"] = username + } + if startDateStr != "" { + response.Filters["start_date"] = startDateStr + } + if endDateStr != "" { + response.Filters["end_date"] = endDateStr + } + if paymentTypeFilter != "" { + response.Filters["payment_type"] = paymentTypeFilter + } + response.Filters["limit"] = strconv.Itoa(limit) + response.Filters["offset"] = strconv.Itoa(offset) + + var targetUsers []*lnbits.User + + // Find target user(s) + if userIDStr != "" { + userID, err := strconv.ParseInt(userIDStr, 10, 64) + if err != nil { + RespondError(w, "Invalid user_id") + return + } + user := &lnbits.User{} + tx := s.Bot.DB.Users.Where("telegram_id = ?", userID).First(user) + if tx.Error != nil { + RespondError(w, "User not found") + return + } + targetUsers = append(targetUsers, user) + } else if username != "" { + user := &lnbits.User{} + tx := s.Bot.DB.Users.Where("telegram_username = ?", username).First(user) + if tx.Error != nil { + RespondError(w, "User not found") + return + } + targetUsers = append(targetUsers, user) + } else { + // Get all users if no specific user requested + var allUsers []*lnbits.User + tx := s.Bot.DB.Users.Find(&allUsers) + if tx.Error != nil { + RespondError(w, "Error fetching users") + return + } + targetUsers = allUsers + } + + uniqueUserMap := make(map[int64]bool) + + // Fetch external payments from LNbits with configurable limit+offset + if includeExternal { + for _, user := range targetUsers { + if user.Wallet == nil { + continue + } + + uniqueUserMap[user.Telegram.ID] = true + + payments, err := s.Bot.Client.PaymentsWithOptions(*user.Wallet, limit+offset, 0) + if err != nil { + log.Errorf("[Analytics] Error fetching payments for user %d: %s", user.Telegram.ID, err.Error()) + continue + } + + for _, payment := range payments { + // Apply date filters + paymentTime := time.Unix(int64(payment.Time), 0) + if !startDate.IsZero() && paymentTime.Before(startDate) { + continue + } + if !endDate.IsZero() && paymentTime.After(endDate) { + continue + } + + // Determine payment type + paymentType := "outgoing" + if payment.Amount > 0 { + paymentType = "incoming" + } + + // Apply payment type filter + if paymentTypeFilter != "" && paymentTypeFilter != "all" && paymentTypeFilter != paymentType { + continue + } + + // Check limit + if len(response.ExternalPayments) >= limit { + break + } + + externalPayment := ExternalPaymentData{ + UserID: user.Telegram.ID, + Username: user.Telegram.Username, + CheckingID: payment.CheckingID, + Pending: payment.Pending, + Amount: payment.Amount, + AmountSats: payment.Amount / 1000, + Fee: payment.Fee, + FeeSats: payment.Fee / 1000, + Memo: payment.Memo, + Time: payment.Time, + Timestamp: paymentTime.Format(time.RFC3339), + PaymentType: paymentType, + Bolt11: payment.Bolt11, + PaymentHash: payment.PaymentHash, + WalletID: payment.WalletID, + } + + response.ExternalPayments = append(response.ExternalPayments, externalPayment) + + // Update summary + response.Summary.TotalExternalCount++ + if paymentType == "incoming" { + response.Summary.ExternalIncoming += externalPayment.AmountSats + } else { + response.Summary.ExternalOutgoing += abs(externalPayment.AmountSats) + } + } + } + } + + // Fetch internal transactions from bot database + if includeInternal { + var internalTxs []telegram.Transaction + dbQuery := s.Bot.DB.Transactions.Model(&telegram.Transaction{}) + + // Apply filters + if userIDStr != "" { + userID, _ := strconv.ParseInt(userIDStr, 10, 64) + dbQuery = dbQuery.Where("from_id = ? OR to_id = ?", userID, userID) + } else if username != "" { + dbQuery = dbQuery.Where("from_user = ? OR to_user = ?", username, username) + } + + if !startDate.IsZero() { + dbQuery = dbQuery.Where("time >= ?", startDate) + } + + if !endDate.IsZero() { + dbQuery = dbQuery.Where("time <= ?", endDate) + } + + dbQuery = dbQuery.Order("time desc").Limit(limit).Offset(offset).Find(&internalTxs) + + if dbQuery.Error != nil { + log.Errorf("[Analytics] Error fetching internal transactions: %s", dbQuery.Error) + } else { + for _, tx := range internalTxs { + uniqueUserMap[tx.FromId] = true + uniqueUserMap[tx.ToId] = true + + internalTx := InternalTransactionData{ + ID: tx.ID, + Time: tx.Time.Format(time.RFC3339), + FromID: tx.FromId, + ToID: tx.ToId, + FromUser: tx.FromUser, + ToUser: tx.ToUser, + Type: tx.Type, + Amount: tx.Amount, + ChatID: tx.ChatID, + ChatName: tx.ChatName, + Memo: tx.Memo, + Success: tx.Success, + } + + response.InternalTxs = append(response.InternalTxs, internalTx) + + // Update summary + response.Summary.TotalInternalCount++ + if tx.Success { + response.Summary.InternalVolume += tx.Amount + } + } + } + } + + response.Summary.UniqueUsers = len(uniqueUserMap) + + // Respond in requested format + if outputFormat == "csv" { + writeCSVResponse(w, response) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +// GetUserTransactionHistory retrieves all transactions for a specific user +// Endpoint: GET /api/v1/analytics/user/{user_id}/transactions +// Query Parameters: +// - limit: Maximum number of transactions per type (default: 1000) +// - offset: Number of transactions to skip for pagination (default: 0) +// - format: Response format - "json" (default) or "csv" +func (s Service) GetUserTransactionHistory(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userIDStr := vars["user_id"] + params := r.URL.Query() + outputFormat := params.Get("format") + + // Parse limit and offset with max caps + limit := 1000 + if limitStr := params.Get("limit"); limitStr != "" { + if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { + limit = parsedLimit + } + } + if limit > maxAnalyticsLimit { + limit = maxAnalyticsLimit + } + offset := 0 + if offsetStr := params.Get("offset"); offsetStr != "" { + if parsedOffset, err := strconv.Atoi(offsetStr); err == nil && parsedOffset >= 0 { + offset = parsedOffset + } + } + if offset > maxAnalyticsOffset { + offset = maxAnalyticsOffset + } + + userID, err := strconv.ParseInt(userIDStr, 10, 64) + if err != nil { + RespondError(w, "Invalid user_id") + return + } + + user := &lnbits.User{} + tx := s.Bot.DB.Users.Where("telegram_id = ?", userID).First(user) + if tx.Error != nil { + RespondError(w, "User not found") + return + } + + response := TransactionAnalyticsResponse{ + Status: StatusOk, + Filters: map[string]string{ + "user_id": userIDStr, + "limit": strconv.Itoa(limit), + "offset": strconv.Itoa(offset), + }, + } + + // Fetch external payments from LNbits + if user.Wallet != nil { + payments, err := s.Bot.Client.PaymentsWithOptions(*user.Wallet, limit+offset, 0) + if err != nil { + log.Errorf("[Analytics] Error fetching payments for user %d: %s", userID, err.Error()) + } else { + for _, payment := range payments { + paymentTime := time.Unix(int64(payment.Time), 0) + paymentType := "outgoing" + if payment.Amount > 0 { + paymentType = "incoming" + } + + if len(response.ExternalPayments) >= limit { + break + } + + externalPayment := ExternalPaymentData{ + UserID: user.Telegram.ID, + Username: user.Telegram.Username, + CheckingID: payment.CheckingID, + Pending: payment.Pending, + Amount: payment.Amount, + AmountSats: payment.Amount / 1000, + Fee: payment.Fee, + FeeSats: payment.Fee / 1000, + Memo: payment.Memo, + Time: payment.Time, + Timestamp: paymentTime.Format(time.RFC3339), + PaymentType: paymentType, + Bolt11: payment.Bolt11, + PaymentHash: payment.PaymentHash, + WalletID: payment.WalletID, + } + + response.ExternalPayments = append(response.ExternalPayments, externalPayment) + response.Summary.TotalExternalCount++ + + if paymentType == "incoming" { + response.Summary.ExternalIncoming += externalPayment.AmountSats + } else { + response.Summary.ExternalOutgoing += abs(externalPayment.AmountSats) + } + } + } + } + + // Fetch internal transactions + var internalTxs []telegram.Transaction + s.Bot.DB.Transactions.Where("from_id = ? OR to_id = ?", userID, userID). + Order("time desc"). + Limit(limit).Offset(offset). + Find(&internalTxs) + + for _, tx := range internalTxs { + internalTx := InternalTransactionData{ + ID: tx.ID, + Time: tx.Time.Format(time.RFC3339), + FromID: tx.FromId, + ToID: tx.ToId, + FromUser: tx.FromUser, + ToUser: tx.ToUser, + Type: tx.Type, + Amount: tx.Amount, + ChatID: tx.ChatID, + ChatName: tx.ChatName, + Memo: tx.Memo, + Success: tx.Success, + } + + response.InternalTxs = append(response.InternalTxs, internalTx) + response.Summary.TotalInternalCount++ + + if tx.Success { + response.Summary.InternalVolume += tx.Amount + } + } + + response.Summary.UniqueUsers = 1 + + // Respond in requested format + if outputFormat == "csv" { + writeCSVResponse(w, response) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +// parseDate parses a date string and validates it falls within reasonable bounds. +func parseDate(dateStr string) (time.Time, error) { + var t time.Time + + // Try parsing as Unix timestamp first + if timestamp, err := strconv.ParseInt(dateStr, 10, 64); err == nil { + if timestamp < minValidTimestamp || timestamp > maxValidTimestamp { + return time.Time{}, fmt.Errorf("timestamp out of range: %d", timestamp) + } + return time.Unix(timestamp, 0), nil + } + + // Try parsing as date string + layouts := []string{ + "2006-01-02", + "2006-01-02T15:04:05", + time.RFC3339, + } + + for _, layout := range layouts { + if parsed, err := time.Parse(layout, dateStr); err == nil { + t = parsed + break + } + } + + if t.IsZero() { + return time.Time{}, fmt.Errorf("unsupported date format: %s", dateStr) + } + + // Validate parsed date is within reasonable bounds + if t.Unix() < minValidTimestamp || t.Unix() > maxValidTimestamp { + return time.Time{}, fmt.Errorf("date out of range: %s", dateStr) + } + + return t, nil +} + +// Helper function to get absolute value +func abs(n int64) int64 { + if n < 0 { + return -n + } + return n +} + +// sanitizeCSVField prevents CSV formula injection by prefixing dangerous characters +// with a single quote. Fields starting with =, +, -, @, tab, or carriage return +// can be interpreted as formulas by spreadsheet software. +func sanitizeCSVField(s string) string { + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + if len(s) > 0 { + switch s[0] { + case '=', '+', '-', '@', '\t': + return "'" + s + } + } + return s +} + +// writeCSVResponse writes the analytics response as a CSV file +func writeCSVResponse(w http.ResponseWriter, response TransactionAnalyticsResponse) { + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", "attachment; filename=transactions.csv") + w.WriteHeader(http.StatusOK) + + writer := csv.NewWriter(w) + defer writer.Flush() + + // Write header + writer.Write([]string{ + "source", "id", "time", "user_id", "username", "from_id", "from_user", + "to_id", "to_user", "type", "amount_sats", "fee_sats", "memo", + "payment_type", "pending", "success", "payment_hash", "chat_id", "chat_name", + }) + + // Write external payments + for _, p := range response.ExternalPayments { + writer.Write([]string{ + "external", + p.CheckingID, + p.Timestamp, + strconv.FormatInt(p.UserID, 10), + sanitizeCSVField(p.Username), + "", "", "", "", // from/to fields not applicable + p.PaymentType, + strconv.FormatInt(p.AmountSats, 10), + strconv.FormatInt(p.FeeSats, 10), + sanitizeCSVField(p.Memo), + p.PaymentType, + strconv.FormatBool(p.Pending), + "", // success not applicable + p.PaymentHash, + "", "", // chat fields not applicable + }) + } + + // Write internal transactions + for _, t := range response.InternalTxs { + writer.Write([]string{ + "internal", + strconv.FormatUint(uint64(t.ID), 10), + t.Time, + "", "", // user_id/username not applicable + strconv.FormatInt(t.FromID, 10), + sanitizeCSVField(t.FromUser), + strconv.FormatInt(t.ToID, 10), + sanitizeCSVField(t.ToUser), + sanitizeCSVField(t.Type), + strconv.FormatInt(t.Amount, 10), + "", // fee not applicable + sanitizeCSVField(t.Memo), + "", "", // payment_type/pending not applicable + strconv.FormatBool(t.Success), + "", // payment_hash not applicable + strconv.FormatInt(t.ChatID, 10), + sanitizeCSVField(t.ChatName), + }) + } +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 34b9fa4c..c4c87299 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -226,3 +226,72 @@ func GenerateHMACSignature(method, path, timestamp, body, secret string) string message := fmt.Sprintf("%s%s%s%s", method, path, timestamp, body) return calculateHMAC(message, secret) } + +// AnalyticsHMACMiddleware validates HMAC signatures for analytics API endpoints +func AnalyticsHMACMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Get timestamp from header + timestampStr := r.Header.Get("X-Timestamp") + if timestampStr == "" { + log.Warn("[Analytics] Missing timestamp in request") + http.Error(w, "Missing timestamp", http.StatusUnauthorized) + return + } + + timestamp, err := strconv.ParseInt(timestampStr, 10, 64) + if err != nil { + log.Warn("[Analytics] Invalid timestamp format") + http.Error(w, "Invalid timestamp", http.StatusBadRequest) + return + } + + // Check if request is not too old (prevent replay attacks) + now := time.Now().Unix() + tolerance := internal.Configuration.API.Analytics.TimestampTolerance + if tolerance == 0 { + tolerance = 300 + } + + if now-timestamp > tolerance { + log.Warnf("[Analytics] Request timestamp too old (age: %d seconds)", now-timestamp) + http.Error(w, "Request expired", http.StatusUnauthorized) + return + } + + // Get signature from header + signature := r.Header.Get("X-HMAC-Signature") + if signature == "" { + log.Warn("[Analytics] Missing HMAC signature") + http.Error(w, "Missing signature", http.StatusUnauthorized) + return + } + + // For GET requests, use query string as the body component + bodyComponent := r.URL.RawQuery + + // Create message to sign: METHOD + PATH + TIMESTAMP + QUERY + message := fmt.Sprintf("%s%s%s%s", r.Method, r.URL.Path, timestampStr, bodyComponent) + + // Try to validate signature against each configured analytics API key + var authenticatedKey string + for keyID, apiKey := range internal.Configuration.API.Analytics.APIKeys { + expectedSignature := calculateHMAC(message, apiKey.HMACSecret) + if hmac.Equal([]byte(signature), []byte(expectedSignature)) { + authenticatedKey = keyID + log.Debugf("[Analytics] HMAC verified for key: %s (%s)", keyID, apiKey.Name) + break + } + } + + if authenticatedKey == "" { + log.Warn("[Analytics] HMAC signature verification failed") + http.Error(w, "Invalid signature", http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), "analytics_api_key", authenticatedKey) + r = r.WithContext(ctx) + + next.ServeHTTP(w, r) + } +} diff --git a/internal/config.go b/internal/config.go index 9fdb7368..2f8bcddd 100644 --- a/internal/config.go +++ b/internal/config.go @@ -74,7 +74,19 @@ type LnbitsConfiguration struct { } type APIConfiguration struct { - Send APISendConfiguration `yaml:"send"` + Send APISendConfiguration `yaml:"send"` + Analytics APIAnalyticsConfiguration `yaml:"analytics"` +} + +type APIAnalyticsConfiguration struct { + Enabled bool `yaml:"enabled"` + APIKeys map[string]AnalyticsAPIKey `yaml:"api_keys"` + TimestampTolerance int64 `yaml:"timestamp_tolerance"` // seconds +} + +type AnalyticsAPIKey struct { + Name string `yaml:"name"` // Descriptive name (e.g. "data-team") + HMACSecret string `yaml:"hmac_secret"` // HMAC secret for this key } type APISendConfiguration struct { @@ -128,6 +140,7 @@ func init() { Configuration.Bot.LNURLHostUrl = hostname checkLnbitsConfiguration() setAPISendDefaults() + setAPIAnalyticsDefaults() } // GetWebhookURL returns the appropriate webhook URL @@ -214,3 +227,38 @@ func setAPISendDefaults() { func IsAPISendEnabled() bool { return Configuration.API.Send.Enabled } + +// setAPIAnalyticsDefaults sets default values for API Analytics configuration +func setAPIAnalyticsDefaults() { + if !Configuration.API.Analytics.Enabled { + log.Infof("Analytics API disabled in configuration") + return + } + + if Configuration.API.Analytics.TimestampTolerance == 0 { + Configuration.API.Analytics.TimestampTolerance = 300 // 5 minutes + } + + if len(Configuration.API.Analytics.APIKeys) == 0 { + log.Errorf("Analytics API enabled but no API keys configured. Disabling analytics API.") + Configuration.API.Analytics.Enabled = false + return + } + + // Reject placeholder/insecure secrets + for keyID, apiKey := range Configuration.API.Analytics.APIKeys { + if strings.Contains(apiKey.HMACSecret, "change-me") || len(apiKey.HMACSecret) < 32 { + log.Errorf("Analytics API key '%s' has an insecure HMAC secret (placeholder or too short). "+ + "Generate a secure secret with: openssl rand -hex 32. Disabling analytics API.", keyID) + Configuration.API.Analytics.Enabled = false + return + } + } + + log.Infof("Analytics API enabled with %d API keys", len(Configuration.API.Analytics.APIKeys)) +} + +// IsAPIAnalyticsEnabled returns whether the Analytics API is enabled +func IsAPIAnalyticsEnabled() bool { + return Configuration.API.Analytics.Enabled +} diff --git a/internal/lnbits/lnbits.go b/internal/lnbits/lnbits.go index 259954bd..63763950 100644 --- a/internal/lnbits/lnbits.go +++ b/internal/lnbits/lnbits.go @@ -136,15 +136,21 @@ func (c Client) Info(w Wallet) (wtx Wallet, err error) { return } -// Payments returns wallet payments +// Payments returns the 60 most recent wallet payments (default behavior). func (c Client) Payments(w Wallet) (wtx Payments, err error) { + return c.PaymentsWithOptions(w, 60, 0) +} + +// PaymentsWithOptions returns wallet payments with configurable limit and offset. +func (c Client) PaymentsWithOptions(w Wallet, limit, offset int) (wtx Payments, err error) { // custom header with invoice key invoiceHeader := req.Header{ "Content-Type": "application/json", "Accept": "application/json", "X-Api-Key": w.Inkey, } - resp, err := req.Get(c.url+"/api/v1/payments?limit=60", invoiceHeader, nil) + url := fmt.Sprintf("%s/api/v1/payments?limit=%d&offset=%d", c.url, limit, offset) + resp, err := req.Get(url, invoiceHeader, nil) if err != nil { return } diff --git a/main.go b/main.go index ad630248..61dc2814 100644 --- a/main.go +++ b/main.go @@ -118,6 +118,15 @@ func startApiServer(bot *telegram.TipBot) { s.AppendAuthorizedRoute(`/api/v1/createinvoice`, api.AuthTypeBasic, api.AccessKeyTypeInvoice, bot.DB.Users, apiService.CreateInvoice, http.MethodPost) s.AppendAuthorizedRoute(`/api/v1/balance`, api.AuthTypeBasic, api.AccessKeyTypeInvoice, bot.DB.Users, apiService.Balance, http.MethodGet) + // Analytics API endpoints (HMAC authenticated) + if internal.IsAPIAnalyticsEnabled() { + s.AppendRoute(`/api/v1/analytics/transactions`, api.AnalyticsHMACMiddleware(apiService.GetTransactionAnalytics), http.MethodGet) + s.AppendRoute(`/api/v1/analytics/user/{user_id}/transactions`, api.AnalyticsHMACMiddleware(apiService.GetUserTransactionHistory), http.MethodGet) + log.Infof("Analytics API endpoints registered with HMAC security") + } else { + log.Infof("Analytics API endpoints disabled in configuration") + } + // Bot pay HTTP API module with wallet-based HMAC security (only if enabled) if internal.IsAPISendEnabled() { s.AppendRoute(`/api/v1/send`, api.WalletHMACMiddleware(apiService.Send), http.MethodPost)