package config import ( "errors" "fmt" "os" "strconv" "strings" "github.com/joho/godotenv" ) const ( EnvDevelop = "develop" EnvProd = "prod" DBDriverSQLite = "sqlite" DBDriverPostgres = "postgres" ) type Config struct { AppName string Env string Port string BaseURL string BuildHash string DBDriver string SQLitePath string PostgresDSN string CORS CORSConfig SessionKey string SMTP SMTPConfig EmailSinkDir string AutoMigrate bool SeedEnabled bool } type CORSConfig struct { Origins []string Headers []string Methods []string Credentials bool } type SMTPConfig struct { Host string Port int Username string Password string From string FromName string } func Load() (*Config, error) { if err := godotenv.Load(); err != nil && !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("load .env: %w", err) } cfg := &Config{ AppName: envOrDefault("APP_NAME", "trustcontact"), Env: envOrDefault("APP_ENV", EnvDevelop), Port: envOrDefault("APP_PORT", "3000"), BaseURL: envOrDefault("APP_BASE_URL", "http://localhost:3000"), BuildHash: envOrDefault("APP_BUILD_HASH", "dev"), DBDriver: envOrDefault("DB_DRIVER", DBDriverSQLite), SQLitePath: envOrDefault("DB_SQLITE_PATH", "data/app.sqlite3"), PostgresDSN: envFirstNonEmpty("DB_POSTGRES_DSN", "DB_PG_DSN"), CORS: CORSConfig{ Origins: envListOrDefault("CORS_ORIGINS", []string{"http://localhost:3000"}), Headers: envListOrDefault("CORS_HEADERS", []string{"Origin", "Content-Type", "Accept", "Authorization", "HX-Request"}), Methods: envListOrDefault("CORS_METHODS", []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}), Credentials: envBoolOrDefault("CORS_CREDENTIALS", true), }, SessionKey: envOrDefault("SESSION_KEY", "change-me-in-prod"), EmailSinkDir: envOrDefault("EMAIL_SINK_DIR", "data/emails"), AutoMigrate: envBoolOrDefault("AUTO_MIGRATE", true), SeedEnabled: envBoolOrDefault("SEED_ENABLED", false), SMTP: SMTPConfig{ Host: envOrDefault("SMTP_HOST", "localhost"), Port: envIntOrDefault("SMTP_PORT", 1025), Username: strings.TrimSpace(os.Getenv("SMTP_USERNAME")), Password: strings.TrimSpace(os.Getenv("SMTP_PASSWORD")), From: envOrDefault("SMTP_FROM", "noreply@example.test"), FromName: envOrDefault("SMTP_FROM_NAME", "Trustcontact"), }, } if err := cfg.Validate(); err != nil { return nil, err } return cfg, nil } func (c *Config) Validate() error { if c.AppName == "" { return errors.New("APP_NAME is required") } switch c.Env { case EnvDevelop, EnvProd: default: return fmt.Errorf("APP_ENV must be one of [%s,%s]", EnvDevelop, EnvProd) } if strings.TrimSpace(c.Port) == "" { return errors.New("APP_PORT is required") } switch c.DBDriver { case DBDriverSQLite: if strings.TrimSpace(c.SQLitePath) == "" { return errors.New("DB_SQLITE_PATH is required when DB_DRIVER=sqlite") } case DBDriverPostgres: if strings.TrimSpace(c.PostgresDSN) == "" { return errors.New("DB_POSTGRES_DSN is required when DB_DRIVER=postgres") } default: return fmt.Errorf("DB_DRIVER must be one of [%s,%s]", DBDriverSQLite, DBDriverPostgres) } if strings.TrimSpace(c.SessionKey) == "" { return errors.New("SESSION_KEY is required") } if c.SMTP.Port <= 0 { return errors.New("SMTP_PORT must be > 0") } if strings.TrimSpace(c.SMTP.From) == "" { return errors.New("SMTP_FROM is required") } if strings.TrimSpace(c.EmailSinkDir) == "" { return errors.New("EMAIL_SINK_DIR is required") } return nil } func envOrDefault(key, fallback string) string { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } return value } func envBoolOrDefault(key string, fallback bool) bool { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.ParseBool(value) if err != nil { return fallback } return parsed } func envIntOrDefault(key string, fallback int) int { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil { return fallback } return parsed } func envListOrDefault(key string, fallback []string) []string { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parts := strings.Split(value, ",") out := make([]string, 0, len(parts)) for _, part := range parts { clean := strings.TrimSpace(part) if clean != "" { out = append(out, clean) } } if len(out) == 0 { return fallback } return out } func envFirstNonEmpty(keys ...string) string { for _, key := range keys { value := strings.TrimSpace(os.Getenv(key)) if value != "" { return value } } return "" }