// Package config loads application configuration from environment variables. package config import ( "fmt" "os" "strconv" "time" "github.com/joho/godotenv" ) type Config struct { Database DatabaseConfig Redis RedisConfig JWT JWTConfig Server ServerConfig Seed SeedConfig Media MediaConfig } type DatabaseConfig struct { URL string } type RedisConfig struct { URL string } type JWTConfig struct { Secret string AccessTTL time.Duration RefreshTTL time.Duration } type ServerConfig struct { Port string GinMode string CORSOrigin string CookieSecure bool } type SeedConfig struct { AdminEmail string AdminPassword string } // MediaConfig configures the pluggable media storage backend. Driver // selects "local" (files on disk, served via /uploads) or "s3" (any // S3-compatible object store). Only the fields relevant to the selected // driver need to be set. type MediaConfig struct { Driver string MaxUploadMB int64 LocalDir string LocalBaseURL string S3Bucket string S3Region string S3Endpoint string S3AccessKeyID string S3SecretKey string S3UsePathStyle bool S3PublicBaseURL string } func Load() (*Config, error) { // The .env file lives at the repo root (next to docker-compose.yml), // but `go run` is commonly invoked from inside backend/. Try both so // either working directory picks it up; missing files are ignored. _ = godotenv.Load(".env") _ = godotenv.Load("../.env") accessMinutes, err := strconv.Atoi(getEnv("ACCESS_TOKEN_TTL_MINUTES", "15")) if err != nil { return nil, fmt.Errorf("invalid ACCESS_TOKEN_TTL_MINUTES: %w", err) } refreshHours, err := strconv.Atoi(getEnv("REFRESH_TOKEN_TTL_HOURS", "168")) if err != nil { return nil, fmt.Errorf("invalid REFRESH_TOKEN_TTL_HOURS: %w", err) } secret := os.Getenv("JWT_SECRET") if secret == "" || secret == "change-me-to-a-long-random-secret" { return nil, fmt.Errorf("JWT_SECRET must be set to a strong, unique value") } dbURL := os.Getenv("DATABASE_URL") if dbURL == "" { return nil, fmt.Errorf("DATABASE_URL must be set") } redisURL := os.Getenv("REDIS_URL") if redisURL == "" { return nil, fmt.Errorf("REDIS_URL must be set") } ginMode := getEnv("GIN_MODE", "debug") cookieSecure, err := strconv.ParseBool(getEnv("COOKIE_SECURE", strconv.FormatBool(ginMode != "debug"))) if err != nil { return nil, fmt.Errorf("invalid COOKIE_SECURE: %w", err) } mediaDriver := getEnv("MEDIA_STORAGE_DRIVER", "local") if mediaDriver != "local" && mediaDriver != "s3" { return nil, fmt.Errorf("invalid MEDIA_STORAGE_DRIVER %q: must be \"local\" or \"s3\"", mediaDriver) } maxUploadMB, err := strconv.ParseInt(getEnv("MEDIA_MAX_UPLOAD_MB", "10"), 10, 64) if err != nil { return nil, fmt.Errorf("invalid MEDIA_MAX_UPLOAD_MB: %w", err) } s3UsePathStyle, err := strconv.ParseBool(getEnv("S3_USE_PATH_STYLE", "false")) if err != nil { return nil, fmt.Errorf("invalid S3_USE_PATH_STYLE: %w", err) } if mediaDriver == "s3" { for _, key := range []string{"S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"} { if os.Getenv(key) == "" { return nil, fmt.Errorf("%s must be set when MEDIA_STORAGE_DRIVER=s3", key) } } } return &Config{ Database: DatabaseConfig{ URL: dbURL, }, Redis: RedisConfig{ URL: redisURL, }, JWT: JWTConfig{ Secret: secret, AccessTTL: time.Duration(accessMinutes) * time.Minute, RefreshTTL: time.Duration(refreshHours) * time.Hour, }, Server: ServerConfig{ Port: getEnv("PORT", "8080"), GinMode: ginMode, CORSOrigin: getEnv("CORS_ORIGIN", "http://localhost:5173"), CookieSecure: cookieSecure, }, Seed: SeedConfig{ AdminEmail: os.Getenv("SEED_ADMIN_EMAIL"), AdminPassword: os.Getenv("SEED_ADMIN_PASSWORD"), }, Media: MediaConfig{ Driver: mediaDriver, MaxUploadMB: maxUploadMB, LocalDir: getEnv("MEDIA_LOCAL_DIR", "./uploads"), LocalBaseURL: getEnv("MEDIA_LOCAL_BASE_URL", "http://localhost:8080/uploads"), S3Bucket: os.Getenv("S3_BUCKET"), S3Region: os.Getenv("S3_REGION"), S3Endpoint: os.Getenv("S3_ENDPOINT"), S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"), S3SecretKey: os.Getenv("S3_SECRET_ACCESS_KEY"), S3UsePathStyle: s3UsePathStyle, S3PublicBaseURL: os.Getenv("S3_PUBLIC_BASE_URL"), }, }, nil } func getEnv(key, fallback string) string { if v, ok := os.LookupEnv(key); ok && v != "" { return v } return fallback }