package config import ( "bufio" "log/slog" "os" "path/filepath" "slices" "strconv" "strings" "time" "filefast/backend/internal/model" ) type Config struct { HTTPAddress string LogLevel slog.Level RoomTTL time.Duration Admin AdminConfig SQLite SQLiteConfig Redis RedisConfig MinIO MinIOConfig Runtime model.RuntimeConfig } type AdminConfig struct { Username string Password string } type MinIOConfig struct { Endpoint string AccessKey string SecretKey string UseSSL bool Bucket string PresignExpiry time.Duration Retention time.Duration UsageAlertLevel int } type RedisConfig struct { Addr string Password string DB int } type SQLiteConfig struct { Path string } func Load() Config { loadDotEnv() retentionHours := envInt("MINIO_RETENTION_HOURS", 2) capacityGB := envInt("MINIO_CAPACITY_GB", 120) return Config{ HTTPAddress: envString("HTTP_ADDR", ":8080"), LogLevel: parseLogLevel(envString("LOG_LEVEL", "info")), RoomTTL: time.Duration(envInt("ROOM_TTL_SECONDS", 300)) * time.Second, Admin: AdminConfig{ Username: envString("ADMIN_USERNAME", ""), Password: envString("ADMIN_PASSWORD", ""), }, SQLite: SQLiteConfig{ Path: envString("SQLITE_PATH", filepath.Join("backend", "data", "filefast.db")), }, Redis: RedisConfig{ Addr: envString("REDIS_ADDR", "127.0.0.1:6379"), Password: envString("REDIS_PASSWORD", ""), DB: envInt("REDIS_DB", 0), }, MinIO: MinIOConfig{ Endpoint: envString("MINIO_ENDPOINT", ""), AccessKey: envString("MINIO_ACCESS_KEY", ""), SecretKey: envString("MINIO_SECRET_KEY", ""), UseSSL: envBool("MINIO_USE_SSL", false), Bucket: envString("MINIO_BUCKET", "filefast-fallback"), PresignExpiry: time.Duration(envInt("MINIO_PRESIGN_MINUTES", 30)) * time.Minute, Retention: time.Duration(retentionHours) * time.Hour, UsageAlertLevel: envInt("MINIO_USAGE_ALERT_PERCENT", 85), }, Runtime: model.RuntimeConfig{ MaxMinIOFallbackSizeBytes: int64(envInt("MAX_MINIO_FALLBACK_GB", 10)) * 1024 * 1024 * 1024, MinIOCapacityBytes: int64(capacityGB) * 1024 * 1024 * 1024, MinIORetentionHours: retentionHours, MinIOUsageAlertPercent: envInt("MINIO_USAGE_ALERT_PERCENT", 85), P2PConnectTimeoutSec: envInt("P2P_CONNECT_TIMEOUT_SEC", 15), TURNConnectTimeoutSec: envInt("TURN_CONNECT_TIMEOUT_SEC", 20), MinIOFallbackEnabled: true, TURNURLs: envCSV("TURN_URLS"), TURNUsername: envString("TURN_USERNAME", ""), TURNPassword: envString("TURN_PASSWORD", ""), }, } } func loadDotEnv() { for _, candidate := range dotEnvCandidates() { if loadDotEnvFile(candidate) { return } } } func dotEnvCandidates() []string { cwd, err := os.Getwd() if err != nil { return []string{".env", filepath.Join("backend", ".env")} } candidates := make([]string, 0, 16) seen := make(map[string]struct{}) current := cwd for { for _, name := range []string{".env", filepath.Join("backend", ".env")} { candidate := filepath.Clean(filepath.Join(current, name)) if _, ok := seen[candidate]; ok { continue } seen[candidate] = struct{}{} candidates = append(candidates, candidate) } parent := filepath.Dir(current) if parent == current { break } current = parent } // Preserve the most local candidates first, but keep the historical relative // fallbacks at the end for compatibility. for _, legacy := range []string{".env", filepath.Join("backend", ".env")} { if !slices.Contains(candidates, legacy) { candidates = append(candidates, legacy) } } return candidates } func loadDotEnvFile(path string) bool { file, err := os.Open(path) if err != nil { return false } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(strings.TrimPrefix(scanner.Text(), "\uFEFF")) if line == "" || strings.HasPrefix(line, "#") { continue } if strings.HasPrefix(line, "export ") { line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) } key, value, ok := strings.Cut(line, "=") if !ok { continue } key = strings.TrimSpace(key) value = strings.TrimSpace(value) if key == "" { continue } if _, exists := os.LookupEnv(key); exists { continue } if len(value) >= 2 { if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) || (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) { value = value[1 : len(value)-1] } } _ = os.Setenv(key, value) } return true } func envString(key, fallback string) string { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } return value } func envInt(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 envBool(key string, fallback bool) bool { value := strings.TrimSpace(strings.ToLower(os.Getenv(key))) if value == "" { return fallback } return value == "1" || value == "true" || value == "yes" } func envCSV(key string) []string { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return nil } parts := strings.Split(raw, ",") values := make([]string, 0, len(parts)) for _, part := range parts { value := strings.TrimSpace(part) if value != "" { values = append(values, value) } } if len(values) == 0 { return nil } return values } func parseLogLevel(level string) slog.Level { switch strings.ToLower(strings.TrimSpace(level)) { case "debug": return slog.LevelDebug case "warn": return slog.LevelWarn case "error": return slog.LevelError default: return slog.LevelInfo } }