diff --git a/.dockerignore b/.dockerignore index 83f83de..4de2d6d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -51,4 +51,7 @@ Thumbs.db # Temporary files *.tmp -*.temp \ No newline at end of file +*.temp + +# Ignore the SQLite database directory +data/ diff --git a/.gitignore b/.gitignore index b665f1f..3f88016 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ src/src # Environment variables .env + +# Data directory +data/ diff --git a/Dockerfile b/Dockerfile index b158d50..739b5e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,32 +1,40 @@ # Build stage -FROM golang:1.25-alpine AS builder +FROM golang:1.25-bookworm AS builder -# Install git and ca-certificates (needed for Go modules) -RUN apk add --no-cache git ca-certificates +# Install build dependencies (needed for CGO + sqlite3) +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + ca-certificates \ + build-essential \ + libsqlite3-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* # Set working directory WORKDIR /app -# Copy go mod files +# Copy go mod files and download dependencies (cache layer) COPY go.mod go.sum ./ - -# Download dependencies RUN go mod download -# Copy source code -COPY src/ ./src/ +# Copy the full repository +COPY . . -# Build the application -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./src +# Build the application with CGO enabled (sqlite requires cgo) +RUN CGO_ENABLED=1 GOOS=linux go build -v -a -installsuffix cgo -o main ./src # Runtime stage -FROM alpine:latest +FROM debian:bookworm-slim -# Install ca-certificates and curl for HTTPS requests and health checks -RUN apk --no-cache add ca-certificates curl +# Install runtime dependencies (sqlite library, certificates, curl) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + libsqlite3-0 \ + curl \ + && rm -rf /var/lib/apt/lists/* # Create non-root user -RUN addgroup -S appgroup && adduser -S appuser -G appgroup +RUN groupadd -r appgroup && useradd -r -g appgroup appuser # Set working directory WORKDIR /app diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index a15d138..a94e1ed 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -5,6 +5,8 @@ services: context: . expose: - "8080" + environment: + - ENVIRONMENT=production volumes: - ./data:/app/data restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index ee5a352..4c6ac1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,8 @@ services: container_name: simple-sync ports: - "8765:8080" + environment: + - ENVIRONMENT=production volumes: - ./data:/app/data restart: unless-stopped diff --git a/src/main.go b/src/main.go index babb22e..b636190 100644 --- a/src/main.go +++ b/src/main.go @@ -1,9 +1,15 @@ package main import ( + "context" + "fmt" "log" + "net/http" "os" + "os/signal" "strconv" + "syscall" + "time" "simple-sync/src/handlers" "simple-sync/src/middleware" @@ -39,7 +45,38 @@ func main() { } // Setup Gin router - router := gin.Default() + // Set Gin mode from environment configuration (production => Release mode) + if envConfig.IsProduction() { + gin.SetMode(gin.ReleaseMode) + } else { + gin.SetMode(gin.DebugMode) + } + + var router *gin.Engine + if envConfig.IsProduction() { + // Production: use New() to control middleware and logging + router = gin.New() + // Recovery middleware to avoid panics crashing the process + router.Use(gin.Recovery()) + // Minimal, structured-ish logger for production + router.Use(gin.LoggerWithConfig(gin.LoggerConfig{ + Formatter: func(param gin.LogFormatterParams) string { + // timestamp, client ip, method, path, status, latency, error (if any) + return fmt.Sprintf("%s - %s \"%s %s\" %d %s %s\n", + param.TimeStamp.Format(time.RFC3339), + param.ClientIP, + param.Method, + param.Path, + param.StatusCode, + param.Latency, + param.ErrorMessage, + ) + }, + })) + } else { + // Development: defaults with logger + recovery + router = gin.Default() + } // Configure trusted proxies (disable for security in development) router.SetTrustedProxies([]string{}) @@ -66,9 +103,31 @@ func main() { // Use port from environment configuration port := envConfig.Port - // Start server - log.Printf("Starting server on port %d", port) - if err := router.Run(":" + strconv.Itoa(port)); err != nil { - log.Fatal("Failed to start server:", err) + // Start server with graceful shutdown + addr := ":" + strconv.Itoa(port) + srv := &http.Server{ + Addr: addr, + Handler: router, + } + + // Start server in background + go func() { + log.Printf("Starting server on %s", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %s\n", err) + } + }() + + // Wait for interrupt signal to gracefully shutdown the server with a timeout + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + <-quit + log.Printf("Shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) } + log.Printf("Server exiting") } diff --git a/src/storage/interface.go b/src/storage/interface.go index 771f880..9ce668f 100644 --- a/src/storage/interface.go +++ b/src/storage/interface.go @@ -52,8 +52,9 @@ func NewStorage() Storage { } // Initialize SQLiteStorage in non-test environments sqlite := NewSQLiteStorage() - if err := sqlite.Initialize(getDefaultDBPath()); err != nil { - log.Fatalf("Failed to initialize SQLite storage: %v", err) + log.Println("Initializing...") + if err := sqlite.Initialize(""); err != nil { + log.Fatalf("Failed to initialize SQLite storage, error: %v", err) } return sqlite } diff --git a/src/storage/sqlite_storage.go b/src/storage/sqlite_storage.go index 33574f2..6a89341 100644 --- a/src/storage/sqlite_storage.go +++ b/src/storage/sqlite_storage.go @@ -3,6 +3,7 @@ package storage import ( "database/sql" "fmt" + "net/url" "os" "path/filepath" "strings" @@ -26,25 +27,22 @@ func NewSQLiteStorage() *SQLiteStorage { // Initialize opens a connection to the SQLite database at path func (s *SQLiteStorage) Initialize(path string) error { if path == "" { - path = getDefaultDBPath() - } - - // Determine and create parent directory unless using an in-memory DB - if path != "" { - isMemory := path == ":memory:" || (strings.HasPrefix(path, "file:") && strings.Contains(path, "memory")) - if !isMemory { - parent := filepath.Dir(path) - if parent != "" && parent != "." { - if err := os.MkdirAll(parent, 0o755); err != nil { - return fmt.Errorf("failed to create db dir: %w", err) - } - } + var err error + path, err = getDbPath() + if err != nil { + return fmt.Errorf("failed to get database path") } } db, err := sql.Open("sqlite3", path) if err != nil { - return err + return fmt.Errorf("failed to open SQLite file: %v, error: %v", path, err) + } + + // Verify connection + if err := db.Ping(); err != nil { + defer db.Close() + return fmt.Errorf("failed to ping database: %v", err) } // Set pragmas for safety/performance @@ -52,36 +50,23 @@ func (s *SQLiteStorage) Initialize(path string) error { // - Allows readers to run while a writer is writing // - Better write throughput for many workloads if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil { - db.Close() - return err + defer db.Close() + return fmt.Errorf("failed to set journal mode: %v", err) } // Enforce foreign key constraints at the SQLite level to maintain relational integrity. if _, err := db.Exec("PRAGMA foreign_keys=ON;"); err != nil { - db.Close() - return err - } - // Set synchronous to NORMAL to balance durability and performance: - // - FULL is the most durable (safer on power loss) but slower - // - NORMAL offers a good trade-off for many server environments - if _, err := db.Exec("PRAGMA synchronous=NORMAL;"); err != nil { - db.Close() - return err + defer db.Close() + return fmt.Errorf("failed to enable foreign keys: %v", err) } // Configure connection pool defaults db.SetMaxOpenConns(25) db.SetMaxIdleConns(5) - // Verify connection - if err := db.Ping(); err != nil { - db.Close() - return err - } - // Apply migrations (idempotent) if err := ApplyMigrations(db); err != nil { - db.Close() - return err + defer db.Close() + return fmt.Errorf("failed to apply migrations: %v", err) } s.db = db @@ -96,7 +81,10 @@ func (s *SQLiteStorage) Close() error { // Close DB and clear pointer err := s.db.Close() s.db = nil - return err + if err != nil { + return fmt.Errorf("failed to close db: %v", err) + } + return nil } func (s *SQLiteStorage) AddEvents(events []models.Event) error { @@ -412,9 +400,44 @@ func (s *SQLiteStorage) GetAclRules() ([]models.AclRule, error) { return rules, nil } -func getDefaultDBPath() string { +// Get the DB path from the DB_PATH environment variable, if it exists. +// Otherwise uses ./data/simple-sync.db +// Returns an absolute filesystem path or ":memory:". The caller builds a +// driver DSN/URI as needed (so callers that need a raw path still work). +func getDbPath() (string, error) { + // Allow explicit in-memory DB if p := os.Getenv("DB_PATH"); p != "" { - return p + if p == ":memory:" { + return p, nil + } + // If caller provided a file: URI, parse and return the path portion + if strings.HasPrefix(p, "file:") { + u, err := url.Parse(p) + if err != nil { + return "", err + } + if u.Path == "" { + return "", fmt.Errorf("file URI has empty path") + } + abs, err := filepath.Abs(u.Path) + if err != nil { + return "", err + } + return abs, nil + } + + // Otherwise treat the value as a filesystem path + abs, err := filepath.Abs(p) + if err != nil { + return "", err + } + return abs, nil + } + + // Default path + abs, err := filepath.Abs("./data/simple-sync.db") + if err != nil { + return "", err } - return "./data/simple-sync.db" + return abs, nil }