// Package config loads runtime settings from the environment, optionally // seeded from a .env file sitting next to the binary or at the module root. package config import ( "bufio" "fmt" "net" "net/url" "os" "path/filepath" "strings" ) // Config holds everything the API needs to start. type Config struct { DatabaseURL string Port string CORSOrigins []string } // Load reads .env (if present) into the process environment, then builds a // Config. Real environment variables always win over .env entries, so // container and CI overrides behave the way people expect. func Load() (*Config, error) { LoadDotEnv() cfg := &Config{ Port: envOr("PORT", "8080"), CORSOrigins: splitAndTrim(envOr("CORS_ORIGINS", "http://localhost:4200")), } if raw := os.Getenv("DATABASE_URL"); raw != "" { cfg.DatabaseURL = raw } else { user := envOr("DB_USER", "postgres") pass := os.Getenv("DB_PASSWORD") host := envOr("DB_HOST", "localhost") port := envOr("DB_PORT", "5432") name := envOr("DB_NAME", "travelhub") ssl := envOr("DB_SSLMODE", "disable") if pass == "" { return nil, fmt.Errorf("database password is not set: define DATABASE_URL or DB_PASSWORD (see .env.example)") } // url.UserPassword escapes credentials for us, which matters because // passwords routinely contain @ : / and other reserved characters. cfg.DatabaseURL = (&url.URL{ Scheme: "postgres", User: url.UserPassword(user, pass), Host: net.JoinHostPort(host, port), Path: "/" + name, RawQuery: "sslmode=" + ssl, }).String() } return cfg, nil } // LoadDotEnv walks up from the working directory looking for a .env file and // applies any keys that are not already set in the environment. Load calls it; // it is exported so tests can pick up the same local credentials without // building a whole Config. func LoadDotEnv() { dir, err := os.Getwd() if err != nil { return } for i := 0; i < 4; i++ { path := filepath.Join(dir, ".env") if applyEnvFile(path) { return } parent := filepath.Dir(dir) if parent == dir { return } dir = parent } } // applyEnvFile reads one .env file. It reports whether the file existed. func applyEnvFile(path string) bool { f, err := os.Open(path) if err != nil { return false } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } key, value, found := strings.Cut(line, "=") if !found { continue } key = strings.TrimSpace(key) value = strings.TrimSpace(value) // Allow quoted values so passwords may contain leading/trailing spaces. if len(value) >= 2 { if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'') { value = value[1 : len(value)-1] } } if _, exists := os.LookupEnv(key); !exists { os.Setenv(key, value) } } return true } func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } func splitAndTrim(s string) []string { parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out }