@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
||||||
@@ -15,6 +16,13 @@ import (
|
|||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// serverReadTimeoutSeconds is the maximum duration to read a request.
|
||||||
|
serverReadTimeoutSeconds = 5
|
||||||
|
// serverWriteTimeoutSeconds is the maximum duration to write a response.
|
||||||
|
serverWriteTimeoutSeconds = 10
|
||||||
|
)
|
||||||
|
|
||||||
//nolint:funlen
|
//nolint:funlen
|
||||||
func main() {
|
func main() {
|
||||||
_ = godotenv.Load() // .env is optional; env vars can also be set externally
|
_ = godotenv.Load() // .env is optional; env vars can also be set externally
|
||||||
@@ -92,6 +100,8 @@ func main() {
|
|||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
|
ReadTimeout: serverReadTimeoutSeconds * time.Second,
|
||||||
|
WriteTimeout: serverWriteTimeoutSeconds * time.Second,
|
||||||
}
|
}
|
||||||
if err := server.ListenAndServe(); err != nil {
|
if err := server.ListenAndServe(); err != nil {
|
||||||
log.Fatalf("Server failed: %v", err)
|
log.Fatalf("Server failed: %v", err)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package config loads environment-based configuration.
|
//loads environment-based configuration.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,17 +9,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// defaultPort is the default HTTP server port.
|
|
||||||
defaultPort = 3001
|
defaultPort = 3001
|
||||||
// defaultDBPort is the default PostgreSQL port.
|
|
||||||
defaultDBPort = 5432
|
defaultDBPort = 5432
|
||||||
// defaultPollIntervalMS is the default poller interval in milliseconds.
|
|
||||||
defaultPollIntervalMS = 60000
|
defaultPollIntervalMS = 60000
|
||||||
// minPollIntervalMS is the minimum allowed poller interval.
|
|
||||||
minPollIntervalMS = 1000
|
minPollIntervalMS = 1000
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds application configuration.
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port int
|
Port int
|
||||||
APIBasePath string
|
APIBasePath string
|
||||||
@@ -59,10 +54,8 @@ func Load() (*Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DSN returns the PostgreSQL data source name for the configured database.
|
|
||||||
func (c *Config) DSN() string {
|
func (c *Config) DSN() string {
|
||||||
if c.DatabaseURL != "" {
|
if c.DatabaseURL != "" {
|
||||||
// pgx defaults to sslmode=prefer, which fails against local Postgres.
|
|
||||||
// Append sslmode=disable if not already specified.
|
// Append sslmode=disable if not already specified.
|
||||||
if !strings.Contains(c.DatabaseURL, "sslmode=") {
|
if !strings.Contains(c.DatabaseURL, "sslmode=") {
|
||||||
sep := "?"
|
sep := "?"
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ type Address struct {
|
|||||||
// AlertType identifies the kind of alert rule.
|
// AlertType identifies the kind of alert rule.
|
||||||
type AlertType string
|
type AlertType string
|
||||||
|
|
||||||
|
// String implements fmt.Stringer.
|
||||||
|
func (a AlertType) String() string { return string(a) }
|
||||||
|
|
||||||
// Alert type constants define the supported alert triggers.
|
// Alert type constants define the supported alert triggers.
|
||||||
const (
|
const (
|
||||||
AlertIncomingTx AlertType = "incoming_tx"
|
AlertIncomingTx AlertType = "incoming_tx"
|
||||||
@@ -24,7 +27,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ValidAlertTypes lists all alert types accepted by the API.
|
// ValidAlertTypes lists all alert types accepted by the API.
|
||||||
var ValidAlertTypes = []AlertType{
|
var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals
|
||||||
AlertIncomingTx,
|
AlertIncomingTx,
|
||||||
AlertOutgoingTx,
|
AlertOutgoingTx,
|
||||||
AlertLargeTransfer,
|
AlertLargeTransfer,
|
||||||
@@ -32,7 +35,7 @@ var ValidAlertTypes = []AlertType{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ThresholdRequiredTypes lists alert types that require a threshold value.
|
// ThresholdRequiredTypes lists alert types that require a threshold value.
|
||||||
var ThresholdRequiredTypes = []AlertType{
|
var ThresholdRequiredTypes = []AlertType{ //nolint:gochecknoglobals
|
||||||
AlertLargeTransfer,
|
AlertLargeTransfer,
|
||||||
AlertBalanceBelow,
|
AlertBalanceBelow,
|
||||||
}
|
}
|
||||||
@@ -120,6 +123,9 @@ type NormalizedTx struct {
|
|||||||
// Direction indicates whether a transaction is incoming or outgoing.
|
// Direction indicates whether a transaction is incoming or outgoing.
|
||||||
type Direction string
|
type Direction string
|
||||||
|
|
||||||
|
// String implements fmt.Stringer.
|
||||||
|
func (d Direction) String() string { return string(d) }
|
||||||
|
|
||||||
// Direction constants indicate the flow of a transaction relative to a watched address.
|
// Direction constants indicate the flow of a transaction relative to a watched address.
|
||||||
const (
|
const (
|
||||||
DirectionIncoming Direction = "incoming"
|
DirectionIncoming Direction = "incoming"
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !domain.IsValidAlertType(body.Type) {
|
if !domain.IsValidAlertType(body.Type) {
|
||||||
types := make([]string, len(domain.ValidAlertTypes))
|
types := make([]string, len(domain.ValidAlertTypes))
|
||||||
for i, t := range domain.ValidAlertTypes {
|
for i, t := range domain.ValidAlertTypes {
|
||||||
types[i] = string(t)
|
types[i] = t.String()
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
"Invalid alert type. Must be one of: "+strings.Join(types, ", "))
|
"Invalid alert type. Must be one of: "+strings.Join(types, ", "))
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package middleware provides HTTP middleware for authentication and context injection.
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||||
)
|
)
|
||||||
@@ -97,7 +99,7 @@ func (m *AddressModel) FindByID(ctx context.Context, id int, userID *string) (*d
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "no rows in result set" {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||||
)
|
)
|
||||||
@@ -21,7 +23,7 @@ func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType do
|
|||||||
`INSERT INTO alert_rules (address_id, type, threshold, enabled)
|
`INSERT INTO alert_rules (address_id, type, threshold, enabled)
|
||||||
VALUES ($1, $2, $3, TRUE)
|
VALUES ($1, $2, $3, TRUE)
|
||||||
RETURNING id, address_id, type, threshold, enabled, created_at`,
|
RETURNING id, address_id, type, threshold, enabled, created_at`,
|
||||||
addressID, string(alertType), threshold,
|
addressID, alertType.String(), threshold,
|
||||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -75,7 +77,7 @@ func (m *AlertRuleModel) FindByID(ctx context.Context, id int, userID *string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "no rows in result set" {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -93,7 +95,7 @@ func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool
|
|||||||
id, enabled,
|
id, enabled,
|
||||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "no rows in result set" {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||||
)
|
)
|
||||||
@@ -23,7 +25,7 @@ func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int
|
|||||||
addressID,
|
addressID,
|
||||||
).Scan(&block)
|
).Scan(&block)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "no rows in result set" {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return 0, false, nil
|
return 0, false, nil
|
||||||
}
|
}
|
||||||
return 0, false, err
|
return 0, false, err
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||||
)
|
)
|
||||||
@@ -26,7 +28,7 @@ func (m *NotificationConfigModel) GetConfig(ctx context.Context, userID string)
|
|||||||
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken,
|
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken,
|
||||||
&c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
&c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "no rows in result set" {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -9,6 +9,22 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// discordHTTPTimeoutSeconds is the timeout for Discord webhook requests.
|
||||||
|
discordHTTPTimeoutSeconds = 10
|
||||||
|
|
||||||
|
// Alert color codes for Discord embeds.
|
||||||
|
colorGreen = 0x00ff00
|
||||||
|
colorOrange = 0xff9900
|
||||||
|
colorRed = 0xff0000
|
||||||
|
colorBlue = 0x0099ff
|
||||||
|
)
|
||||||
|
|
||||||
|
// discordHTTPClient is a shared HTTP client with a timeout for Discord requests.
|
||||||
|
var discordHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||||
|
Timeout: discordHTTPTimeoutSeconds * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
type AlertMetadata struct {
|
type AlertMetadata struct {
|
||||||
TxHash string
|
TxHash string
|
||||||
AddressLabel string
|
AddressLabel string
|
||||||
@@ -73,7 +89,9 @@ func SendDiscordNotification(webhookURL, message string, meta AlertMetadata) (bo
|
|||||||
return false, fmt.Errorf("marshal discord payload: %w", err)
|
return false, fmt.Errorf("marshal discord payload: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
|
resp, err := discordHTTPClient.Post(
|
||||||
|
webhookURL, "application/json", bytes.NewReader(body),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to send Discord notification: %v", err)
|
log.Printf("Failed to send Discord notification: %v", err)
|
||||||
return false, err
|
return false, err
|
||||||
@@ -98,7 +116,9 @@ func TestDiscordWebhook(webhookURL string) (bool, error) {
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
|
resp, err := discordHTTPClient.Post(
|
||||||
|
webhookURL, "application/json", bytes.NewReader(body),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Discord webhook test failed: %v", err)
|
log.Printf("Discord webhook test failed: %v", err)
|
||||||
return false, err
|
return false, err
|
||||||
@@ -111,14 +131,14 @@ func TestDiscordWebhook(webhookURL string) (bool, error) {
|
|||||||
func colorForAlertType(alertType string) int {
|
func colorForAlertType(alertType string) int {
|
||||||
switch alertType {
|
switch alertType {
|
||||||
case "incoming_tx":
|
case "incoming_tx":
|
||||||
return 0x00ff00 // Green
|
return colorGreen
|
||||||
case "outgoing_tx":
|
case "outgoing_tx":
|
||||||
return 0xff9900 // Orange
|
return colorOrange
|
||||||
case "large_transfer":
|
case "large_transfer":
|
||||||
return 0xff0000 // Red
|
return colorRed
|
||||||
case "balance_below":
|
case "balance_below":
|
||||||
return 0xff0000 // Red
|
return colorRed
|
||||||
default:
|
default:
|
||||||
return 0x0099ff // Blue
|
return colorBlue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,7 +170,11 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
|
|||||||
|
|
||||||
// Send Discord notification (non-fatal on failure)
|
// Send Discord notification (non-fatal on failure)
|
||||||
if addr != nil {
|
if addr != nil {
|
||||||
go s.sendNotification(ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address)
|
go func() {
|
||||||
|
s.sendNotification(
|
||||||
|
ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address,
|
||||||
|
)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Reference in New Issue
Block a user