Cleanup
Some checks are pending
check / check (push) Waiting to run

This commit is contained in:
KS Jannette
2026-02-28 20:42:57 -05:00
parent 90a338d46c
commit 7357315810
11 changed files with 69 additions and 27 deletions

View File

@@ -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)

View File

@@ -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 := "?"

View File

@@ -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"

View File

@@ -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, ", "))

View File

@@ -1,3 +1,4 @@
// Package middleware provides HTTP middleware for authentication and context injection.
package middleware package middleware
import ( import (

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
} }
} }

View File

@@ -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