diff --git a/.gitignore b/.gitignore index de2736c..777f96f 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,4 @@ crash.*.log # personal docs/ideas.md backup/ +.kiro \ No newline at end of file diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ace7bce..751427e 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -1,35 +1,31 @@ package main import ( + "capuchin/internal/config" "capuchin/internal/database" "capuchin/internal/handlers" + "capuchin/internal/logger" "capuchin/internal/routes" "capuchin/internal/services" - "log" + "net/http" "time" "github.com/gin-gonic/gin" ) func main() { - // Bootstrapping schema at startup to keep local/dev deployments self-contained. - if err := database.Connect(); err != nil { - log.Fatalf("Startup failed: %v", err) - } - database.InitSchema() + database.Connect(config.Config) + database.StartHealthMonitor() - // Periodic cleanup prevents the revoked-token table from growing forever. go func() { ticker := time.NewTicker(1 * time.Hour) - defer ticker.Stop() for range ticker.C { if err := database.CleanupTokens(); err != nil { - log.Printf("Error cleaning up expired tokens: %v", err) + logger.Error("token.cleanup", "failed to delete expired tokens", err) } } }() - // Handlers depend on interfaces so business logic can be swapped in tests. authService := services.NewAuthService() todoService := services.NewTodoService() @@ -38,21 +34,18 @@ func main() { r := gin.Default() - // Allow cross-origin requests so a separately hosted frontend can call this API. - // Restrict this in production to trusted origins. + // Restrict Access-Control-Allow-Origin to trusted origins in production. r.Use(func(c *gin.Context) { c.Writer.Header().Set("Access-Control-Allow-Origin", "*") c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE, PATCH") c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if c.Request.Method == "OPTIONS" { - // Short-circuit preflight checks to avoid running downstream handlers. - c.AbortWithStatus(204) + c.AbortWithStatus(http.StatusNoContent) return } c.Next() }) - // Keep route wiring centralized so auth boundaries are easy to audit. routes.SetupRoutes(r, authHandler, todoHandler) r.Run(":8080") diff --git a/backend/go.mod b/backend/go.mod index 0a580fc..963f5b7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,7 +6,6 @@ require ( github.com/gin-gonic/gin v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 - github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.11.2 golang.org/x/crypto v0.48.0 ) diff --git a/backend/go.sum b/backend/go.sum index cd59fd3..1b82300 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -34,8 +34,6 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index bb2537a..31eb087 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1,15 +1,11 @@ package config import ( - "errors" "fmt" "log" "os" - "path/filepath" "strconv" "strings" - - "github.com/joho/godotenv" ) type AppConfig struct { @@ -24,8 +20,6 @@ var Config AppConfig var JWTKey []byte func init() { - loadEnvFile() - postgresPort, err := postgresPortFromEnv() if err != nil { log.Fatal(err) @@ -53,50 +47,6 @@ func init() { log.Println("Configuration loaded successfully.") } -func loadEnvFile() { - cwd, err := os.Getwd() - if err != nil { - log.Fatalf("failed to determine current working directory: %v", err) - } - - envPath, err := findEnvFile(cwd) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - log.Println("No .env file found in current or parent directories; using existing environment variables.") - return - } - log.Fatalf("failed to locate .env file: %v", err) - } - - if err := godotenv.Load(envPath); err != nil { - log.Fatalf("failed to load .env file %q: %v", envPath, err) - } - - log.Printf("Loaded environment variables from %s", envPath) -} - -func findEnvFile(startDir string) (string, error) { - dir := startDir - for { - candidate := filepath.Join(dir, ".env") - info, err := os.Stat(candidate) - if err == nil && !info.IsDir() { - return candidate, nil - } - if err != nil && !errors.Is(err, os.ErrNotExist) { - return "", err - } - - parent := filepath.Dir(dir) - if parent == dir { - break - } - dir = parent - } - - return "", os.ErrNotExist -} - func postgresPortFromEnv() (int, error) { rawPort := os.Getenv("POSTGRES_PORT") if rawPort == "" { diff --git a/backend/internal/database/db.go b/backend/internal/database/db.go index 10fd1e7..d6ee3f4 100644 --- a/backend/internal/database/db.go +++ b/backend/internal/database/db.go @@ -2,78 +2,173 @@ package database import ( "capuchin/internal/config" + "capuchin/internal/logger" + "context" "database/sql" "fmt" - "log" + "sync/atomic" "time" _ "github.com/lib/pq" ) -var DB *sql.DB +// db is the shared connection pool. Access via GetDB(). +// Uses atomic.Pointer to avoid data races on connect/reconnect. +var db atomic.Pointer[sql.DB] + +// dbHealthy is 1 when the last monitor ping succeeded, 0 otherwise. +var dbHealthy atomic.Int32 + +// degraded is a channel used to signal the monitor to switch to fast polling. +// Buffered so callers never block. +var degraded = make(chan struct{}, 1) const ( - dbMaxStartupAttempts = 5 - dbStartupBaseDelay = 2 * time.Second - dbStartupMaxDelay = 30 * time.Second + connectRetryInterval = 5 * time.Second + healthyPingInterval = 30 * time.Second + degradedPingInterval = 5 * time.Second + pingTimeout = 2 * time.Second ) -func Connect() error { - connStr := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=disable", - config.Config.POSTGRES_HOST, - config.Config.POSTGRES_USER, - config.Config.POSTGRES_PASSWORD, - config.Config.POSTGRES_DB, - config.Config.POSTGRES_PORT, +// GetDB returns the active connection pool, or nil if not yet connected. +func GetDB() *sql.DB { + return db.Load() +} + +// IsHealthy reports the cached DB health state set by StartHealthMonitor. +func IsHealthy() bool { + return dbHealthy.Load() == 1 +} + +// MarkDegraded signals the monitor to switch to fast polling immediately. +// Safe to call from any goroutine; never blocks. +func MarkDegraded() { + select { + case degraded <- struct{}{}: + default: // already signalled, drop + } +} + +// Connect opens the connection pool once and launches a background goroutine +// that pings until ready, retrying on failure. Returns immediately. +func Connect(cfg config.AppConfig) { + connStr := fmt.Sprintf( + "host=%s user=%s password=%s dbname=%s port=%d sslmode=disable", + cfg.POSTGRES_HOST, + cfg.POSTGRES_USER, + cfg.POSTGRES_PASSWORD, + cfg.POSTGRES_DB, + cfg.POSTGRES_PORT, ) - var err error - DB, err = sql.Open("postgres", connStr) + // sql.Open only validates the DSN — allocate the pool once outside the retry loop. + conn, err := sql.Open("postgres", connStr) if err != nil { - return fmt.Errorf("failed to open database: %w", err) + logger.Error("database", "failed to open connection pool", err) + return } - // Conservative pool settings avoid exhausting DB connections in small deployments. - DB.SetMaxOpenConns(25) - DB.SetMaxIdleConns(5) - // Recycling connections helps recover from stale network state over long uptimes. - DB.SetConnMaxLifetime(5 * time.Minute) + conn.SetMaxOpenConns(25) + conn.SetMaxIdleConns(5) + conn.SetConnMaxLifetime(5 * time.Minute) - if err = pingWithRetry(); err != nil { - DB.Close() - return fmt.Errorf("database unavailable after %d attempts: %w", dbMaxStartupAttempts, err) - } + go func() { + for { + ctx, cancel := context.WithTimeout(context.Background(), pingTimeout) + err := conn.PingContext(ctx) + cancel() - log.Println("Database connection established") - return nil -} + if err != nil { + logger.Error("database", "ping failed, retrying", err) + time.Sleep(connectRetryInterval) + continue + } -func pingWithRetry() error { - delay := dbStartupBaseDelay - for attempt := 1; attempt <= dbMaxStartupAttempts; attempt++ { - if err := DB.Ping(); err == nil { - return nil - } else { - log.Printf("Database ping failed (attempt %d/%d): %v", attempt, dbMaxStartupAttempts, err) + db.Store(conn) + dbHealthy.Store(1) + logger.Info("database", "connection established") + return } - if attempt < dbMaxStartupAttempts { - log.Printf("Retrying in %s...", delay) - time.Sleep(delay) - delay *= 2 - if delay > dbStartupMaxDelay { - delay = dbStartupMaxDelay + }() +} + +// StartHealthMonitor runs a two-speed ping loop: +// - healthy: pings every 30s for observability +// - degraded: pings every 5s to detect recovery as soon as possible +// +// Switches to degraded mode when a ping fails or MarkDegraded() is called. +// Backs off to healthy interval once a ping succeeds again. +func StartHealthMonitor() { + go func() { + isDegraded := false + timer := time.NewTimer(healthyPingInterval) + defer timer.Stop() + + for { + select { + case <-degraded: + // A query error was reported — switch to fast polling immediately + // without waiting for the current timer to fire. + if !isDegraded { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + isDegraded = true + timer.Reset(degradedPingInterval) + } + + case <-timer.C: + pingMonitor(isDegraded) + if IsHealthy() { + isDegraded = false + timer.Reset(healthyPingInterval) + } else { + isDegraded = true + timer.Reset(degradedPingInterval) + } } } - } - return fmt.Errorf("database unreachable after %d attempts", dbMaxStartupAttempts) + }() } -func InitSchema() { - log.Println("Database connection initialized. Assuming schema is already present.") +func pingMonitor(isDegraded bool) { + conn := GetDB() + if conn == nil { + dbHealthy.Store(0) + logger.Warn("database.monitor", "DB not yet connected", nil) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), pingTimeout) + err := conn.PingContext(ctx) + cancel() + + if err != nil { + dbHealthy.Store(0) + logger.Warn("database.monitor", "DB unreachable", err) + return + } + + if isDegraded { + // Only log recovery and stats when coming back from a degraded state. + stats := conn.Stats() + logger.Info("database.monitor", fmt.Sprintf( + "DB recovered — open=%d idle=%d waitCount=%d", + stats.OpenConnections, stats.Idle, stats.WaitCount, + )) + } + dbHealthy.Store(1) } +// CleanupTokens deletes expired blacklisted tokens. func CleanupTokens() error { - // Expired tokens can be dropped because JWT expiration already invalidates them. - _, err := DB.Exec("DELETE FROM blacklisted_tokens WHERE expired_at < $1", time.Now()) + conn := GetDB() + if conn == nil { + return fmt.Errorf("database: not connected") + } + _, err := conn.Exec("DELETE FROM blacklisted_tokens WHERE expired_at < NOW()") return err } diff --git a/backend/internal/database/errors.go b/backend/internal/database/errors.go new file mode 100644 index 0000000..9615a0a --- /dev/null +++ b/backend/internal/database/errors.go @@ -0,0 +1,13 @@ +package database + +import "database/sql" + +// HandleQueryError signals the health monitor to switch to fast polling +// when a real connectivity error occurs, as opposed to expected errors +// like sql.ErrNoRows which don't indicate DB unavailability. +func HandleQueryError(err error) { + if err == nil || err == sql.ErrNoRows { + return + } + MarkDegraded() +} diff --git a/backend/internal/logger/logger.go b/backend/internal/logger/logger.go new file mode 100644 index 0000000..2f5e87e --- /dev/null +++ b/backend/internal/logger/logger.go @@ -0,0 +1,61 @@ +// Package logger provides structured JSON logging with component tagging. +// Log lines are machine-parseable so monitoring tools can filter by component, +// level, or error type without regex scraping. +package logger + +import ( + "encoding/json" + "log" + "os" + "time" +) + +type Level string + +const ( + LevelInfo Level = "INFO" + LevelWarn Level = "WARN" + LevelError Level = "ERROR" +) + +type entry struct { + Time string `json:"time"` + Level Level `json:"level"` + Component string `json:"component"` + Message string `json:"msg"` + Error string `json:"error,omitempty"` +} + +var out = log.New(os.Stdout, "", 0) + +func write(level Level, component, msg, errStr string) { + e := entry{ + Time: time.Now().UTC().Format(time.RFC3339), + Level: level, + Component: component, + Message: msg, + Error: errStr, + } + b, _ := json.Marshal(e) + out.Println(string(b)) +} + +func Info(component, msg string) { + write(LevelInfo, component, msg, "") +} + +func Warn(component, msg string, err error) { + errStr := "" + if err != nil { + errStr = err.Error() + } + write(LevelWarn, component, msg, errStr) +} + +func Error(component, msg string, err error) { + errStr := "" + if err != nil { + errStr = err.Error() + } + write(LevelError, component, msg, errStr) +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 10f9adf..55f3adf 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -24,8 +24,9 @@ func AuthRequired() gin.HandlerFunc { var exists bool // Check revocation before claim extraction so logout takes effect immediately. - err := database.DB.QueryRow("SELECT EXISTS(SELECT 1 FROM blacklisted_tokens WHERE token=$1)", tokenStr).Scan(&exists) + err := database.GetDB().QueryRow("SELECT EXISTS(SELECT 1 FROM blacklisted_tokens WHERE token=$1)", tokenStr).Scan(&exists) if err != nil && err != sql.ErrNoRows { + database.HandleQueryError(err) c.AbortWithStatusJSON(503, gin.H{"error": "authentication service unavailable"}) return } diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index dd77457..d29c6a8 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -1,6 +1,7 @@ package routes import ( + "capuchin/internal/database" "capuchin/internal/handlers" "capuchin/internal/middleware" @@ -12,6 +13,10 @@ func SetupRoutes(router *gin.Engine, authHandler *handlers.AuthHandler, todoHand router.Use(middleware.ErrorHandler()) router.GET("/health", func(c *gin.Context) { + if !database.IsHealthy() { + c.JSON(503, gin.H{"status": "unavailable"}) + return + } c.JSON(200, gin.H{"status": "ok"}) }) diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go index 7b6da99..2c6dd86 100644 --- a/backend/internal/services/auth_service.go +++ b/backend/internal/services/auth_service.go @@ -3,6 +3,7 @@ package services import ( "capuchin/internal/config" "capuchin/internal/database" + "capuchin/internal/logger" "capuchin/internal/models" "errors" "strings" @@ -44,13 +45,15 @@ func (s *authService) Signup(email, password string) (*models.User, error) { PasswordHash: string(hash), } - _, err = database.DB.Exec("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)", u.ID, u.Email, u.PasswordHash) + _, err = database.GetDB().Exec("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)", u.ID, u.Email, u.PasswordHash) if err != nil { errStr := err.Error() // Convert storage-specific duplicate key errors into a stable domain error for handlers. if strings.Contains(errStr, "unique constraint") || strings.Contains(errStr, "duplicate key value") { return nil, ErrUserExists } + logger.Error("auth.signup", "insert user failed", err) + database.HandleQueryError(err) return nil, ErrDatabase } @@ -59,7 +62,7 @@ func (s *authService) Signup(email, password string) (*models.User, error) { func (s *authService) Login(email, password string) (string, error) { var u models.User - err := database.DB.QueryRow("SELECT id, email, password_hash FROM users WHERE email=$1", email).Scan(&u.ID, &u.Email, &u.PasswordHash) + err := database.GetDB().QueryRow("SELECT id, email, password_hash FROM users WHERE email=$1", email).Scan(&u.ID, &u.Email, &u.PasswordHash) if err != nil { // Use one response for unknown user and wrong password to avoid account enumeration. return "", ErrInvalidCredentials @@ -116,8 +119,10 @@ func (s *authService) Logout(tokenStr string) error { } // Idempotent logout avoids surfacing harmless duplicate requests as server errors. - _, err := database.DB.Exec("INSERT INTO blacklisted_tokens (token, expired_at) VALUES ($1, $2) ON CONFLICT (token) DO NOTHING", tokenStr, expTime) + _, err := database.GetDB().Exec("INSERT INTO blacklisted_tokens (token, expired_at) VALUES ($1, $2) ON CONFLICT (token) DO NOTHING", tokenStr, expTime) if err != nil { + logger.Error("auth.logout", "insert blacklisted token failed", err) + database.HandleQueryError(err) return ErrDatabase } diff --git a/backend/internal/services/todo_service.go b/backend/internal/services/todo_service.go index df36148..d09e867 100644 --- a/backend/internal/services/todo_service.go +++ b/backend/internal/services/todo_service.go @@ -2,6 +2,7 @@ package services import ( "capuchin/internal/database" + "capuchin/internal/logger" "capuchin/internal/models" "database/sql" "errors" @@ -28,8 +29,10 @@ func NewTodoService() TodoService { func (s *todoService) GetTodos(userID uuid.UUID) ([]models.Todo, error) { // Scope every read by user_id so one user can never read another user's todos. - rows, err := database.DB.Query("SELECT id, item, completed FROM todos WHERE user_id=$1", userID) + rows, err := database.GetDB().Query("SELECT id, item, completed FROM todos WHERE user_id=$1", userID) if err != nil { + logger.Error("todo.get", "query failed", err) + database.HandleQueryError(err) return nil, ErrDatabase } defer rows.Close() @@ -45,6 +48,8 @@ func (s *todoService) GetTodos(userID uuid.UUID) ([]models.Todo, error) { } if err := rows.Err(); err != nil { + logger.Error("todo.get", "rows iteration failed", err) + database.HandleQueryError(err) return nil, ErrDatabase } @@ -59,8 +64,10 @@ func (s *todoService) AddTodo(userID uuid.UUID, item string, completed bool) (*m Completed: completed, } - _, err := database.DB.Exec("INSERT INTO todos (id, item, completed, user_id) VALUES ($1, $2, $3, $4)", t.ID, t.Item, t.Completed, t.UserID) + _, err := database.GetDB().Exec("INSERT INTO todos (id, item, completed, user_id) VALUES ($1, $2, $3, $4)", t.ID, t.Item, t.Completed, t.UserID) if err != nil { + logger.Error("todo.add", "insert failed", err) + database.HandleQueryError(err) return nil, ErrDatabase } return t, nil @@ -70,18 +77,20 @@ func (s *todoService) UpdateTodo(userID, todoID uuid.UUID, item *string, complet if item == nil && completed == nil { // Empty PATCH requests are treated as a read to keep the endpoint idempotent. var t models.Todo - err := database.DB.QueryRow("SELECT id, item, completed FROM todos WHERE id=$1 AND user_id=$2", todoID, userID).Scan(&t.ID, &t.Item, &t.Completed) + err := database.GetDB().QueryRow("SELECT id, item, completed FROM todos WHERE id=$1 AND user_id=$2", todoID, userID).Scan(&t.ID, &t.Item, &t.Completed) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrTodoNotFound } + logger.Error("todo.update", "read-only fetch failed", err) + database.HandleQueryError(err) return nil, ErrDatabase } return &t, nil } var t models.Todo - err := database.DB.QueryRow(` + err := database.GetDB().QueryRow(` UPDATE todos -- COALESCE preserves existing values when fields are omitted from PATCH payloads. SET item = COALESCE($1, item), @@ -93,14 +102,18 @@ func (s *todoService) UpdateTodo(userID, todoID uuid.UUID, item *string, complet if errors.Is(err, sql.ErrNoRows) { return nil, ErrTodoNotFound } + logger.Error("todo.update", "update query failed", err) + database.HandleQueryError(err) return nil, ErrDatabase } return &t, nil } func (s *todoService) DeleteTodo(userID, todoID uuid.UUID) error { - res, err := database.DB.Exec("DELETE FROM todos WHERE id=$1 AND user_id=$2", todoID, userID) + res, err := database.GetDB().Exec("DELETE FROM todos WHERE id=$1 AND user_id=$2", todoID, userID) if err != nil { + logger.Error("todo.delete", "delete query failed", err) + database.HandleQueryError(err) return ErrDatabase }