package config import ( "os" "path/filepath" "strconv" "strings" "time" ) type Config struct { AppEnv string HTTPAddr string AppTitle string PostgresDSN string RedisAddr string RedisPassword string RedisDB int SessionCookieName string CookieSecure bool SessionTTL time.Duration SessionSlideAfter time.Duration CORSOrigins []string GeneratedRoot string RustFSEndpoint string RustFSBucket string RustFSAccessKey string RustFSSecretKey string } func Load() (*Config, error) { loadDotEnv() wd, err := os.Getwd() if err != nil { return nil, err } cfg := &Config{ AppEnv: envString("APP_ENV", "development"), HTTPAddr: envString("HTTP_ADDR", ":6061"), AppTitle: envString("APP_TITLE", "Vivid AI"), PostgresDSN: envString("POSTGRES_DSN", "host=127.0.0.1 user=postgres password=postgres dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai"), RedisAddr: envString("REDIS_ADDR", "127.0.0.1:6379"), RedisPassword: envString("REDIS_PASSWORD", ""), RedisDB: envInt("REDIS_DB", 0), SessionCookieName: envString("SESSION_COOKIE_NAME", "vivid_session"), CookieSecure: envBool("COOKIE_SECURE", false), SessionTTL: time.Duration(envInt("SESSION_TTL_HOURS", 24)) * time.Hour, SessionSlideAfter: time.Duration(envInt("SESSION_SLIDE_AFTER_HOURS", 22)) * time.Hour, CORSOrigins: envList("CORS_ORIGINS", []string{"http://localhost:5173", "http://127.0.0.1:5173"}), GeneratedRoot: filepath.Clean(envString( "GENERATED_ROOT", // vivid-ai's own data dir (backend/data/generated) — NOT the Python // original's tree. Generated outputs and user-uploaded reference // images both live here and are served (cookie-authed) via /images. filepath.Join(wd, "data", "generated"), )), RustFSEndpoint: envString("RUSTFS_ENDPOINT", ""), RustFSBucket: envString("RUSTFS_BUCKET", ""), RustFSAccessKey: envString("RUSTFS_ACCESS_KEY", ""), RustFSSecretKey: envString("RUSTFS_SECRET_KEY", ""), } return cfg, nil } // loadDotEnv loads a .env file (KEY=VALUE per line) into the process environment // before config is read. Real environment variables always win — .env only fills // keys that aren't already set. Searches ENV_FILE, then walks up from the working // directory so it works whether the binary runs from backend/ or the repo root. func loadDotEnv() { for _, path := range dotEnvCandidates() { data, err := os.ReadFile(path) if err != nil { continue } applyDotEnv(string(data)) return } } func dotEnvCandidates() []string { var out []string if v := strings.TrimSpace(os.Getenv("ENV_FILE")); v != "" { out = append(out, v) } wd, err := os.Getwd() if err != nil { return out } dir := wd for i := 0; i < 4; i++ { out = append(out, filepath.Join(dir, ".env")) parent := filepath.Dir(dir) if parent == dir { break } dir = parent } return out } func applyDotEnv(content string) { for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "export ")) if line == "" || strings.HasPrefix(line, "#") { continue } eq := strings.IndexByte(line, '=') if eq < 0 { continue } key := strings.TrimSpace(line[:eq]) val := strings.TrimSpace(line[eq+1:]) if len(val) >= 2 { if (val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'') { val = val[1 : len(val)-1] } } if key == "" { continue } // Real env wins: only set keys that aren't already present. if _, ok := os.LookupEnv(key); !ok { _ = os.Setenv(key, val) } } } func envString(key, fallback string) string { if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } return fallback } func envInt(key string, fallback int) int { if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" { if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { return n } } return fallback } func envList(key string, fallback []string) []string { if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" { parts := strings.Split(v, ",") out := make([]string, 0, len(parts)) for _, part := range parts { s := strings.TrimSpace(part) if s != "" { out = append(out, s) } } if len(out) > 0 { return out } } return fallback } func envBool(key string, fallback bool) bool { if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" { switch strings.ToLower(strings.TrimSpace(v)) { case "1", "true", "yes", "on": return true case "0", "false", "no", "off": return false } } return fallback }