Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,7 @@ Thumbs.db

# Temporary files
*.tmp
*.temp
*.temp

# Ignore the SQLite database directory
data/
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ src/src

# Environment variables
.env

# Data directory
data/
36 changes: 22 additions & 14 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.coolify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ services:
context: .
expose:
- "8080"
environment:
- ENVIRONMENT=production
volumes:
- ./data:/app/data
restart: unless-stopped
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ services:
container_name: simple-sync
ports:
- "8765:8080"
environment:
- ENVIRONMENT=production
volumes:
- ./data:/app/data
restart: unless-stopped
Expand Down
69 changes: 64 additions & 5 deletions src/main.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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{})
Expand All @@ -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")
}
5 changes: 3 additions & 2 deletions src/storage/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
97 changes: 60 additions & 37 deletions src/storage/sqlite_storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package storage
import (
"database/sql"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
Expand All @@ -26,62 +27,46 @@ 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
// Use WAL (Write-Ahead Logging) to improve concurrency and crash resilience:
// - 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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}