package config import ( "fmt" "math" "os" "path/filepath" "strconv" "strings" "time" ) type Config struct { AppName string AppVersion string Environment string Addr string BaseURL string DataDir string AdminToken string StaticDir string TemplateDir string ReadHeaderTimeout time.Duration ReadTimeout time.Duration WriteTimeout time.Duration IdleTimeout time.Duration TrustedProxies []string JobsEnabled bool CleanupEnabled bool CleanupEvery time.Duration ThumbnailEnabled bool ThumbnailEvery time.Duration MaxUploadSize int64 DefaultSettings SettingsDefaults } type SettingsDefaults struct { AnonymousUploadsEnabled bool AnonymousMaxUploadMB float64 AnonymousDailyUploadMB float64 UserDailyUploadMB float64 DefaultUserStorageMB float64 UsageRetentionDays int LocalStorageMaxGB float64 AnonymousMaxDays int UserMaxDays int AnonymousDailyBoxes int UserDailyBoxes int AnonymousActiveBoxes int UserActiveBoxes int ShortWindowRequests int ShortWindowSeconds int AnonymousStorageBackend string UserStorageBackend string } func Load() (Config, error) { cfg := Config{ AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"), AppVersion: envString("APP_VERSION", "dev"), Environment: envString("WARPBOX_ENV", "development"), Addr: envString("WARPBOX_ADDR", ":8080"), BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"), DataDir: envString("WARPBOX_DATA_DIR", defaultPath("data")), AdminToken: envString("WARPBOX_ADMIN_TOKEN", ""), StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")), TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")), ReadHeaderTimeout: envDuration("WARPBOX_READ_HEADER_TIMEOUT", 15*time.Second), ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 0), WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 0), IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second), TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"), JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true), CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true), CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour), ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true), ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute), MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default. DefaultSettings: SettingsDefaults{ AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true), AnonymousMaxUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_MAX_UPLOAD_MB", 512), AnonymousDailyUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB", 2048), UserDailyUploadMB: envMegabytesLimitFloat("WARPBOX_USER_DAILY_UPLOAD_MB", 8192), DefaultUserStorageMB: envMegabytesFloat("WARPBOX_DEFAULT_USER_STORAGE_MB", 51200), UsageRetentionDays: envInt("WARPBOX_USAGE_RETENTION_DAYS", 30), LocalStorageMaxGB: envGigabytesFloat("WARPBOX_LOCAL_STORAGE_MAX_GB", 100), AnonymousMaxDays: envInt("WARPBOX_ANONYMOUS_MAX_DAYS", 30), UserMaxDays: envInt("WARPBOX_USER_MAX_DAYS", 90), AnonymousDailyBoxes: envInt("WARPBOX_ANONYMOUS_DAILY_BOXES", 100), UserDailyBoxes: envInt("WARPBOX_USER_DAILY_BOXES", 250), AnonymousActiveBoxes: envInt("WARPBOX_ANONYMOUS_ACTIVE_BOXES", 500), UserActiveBoxes: envInt("WARPBOX_USER_ACTIVE_BOXES", 1000), ShortWindowRequests: envInt("WARPBOX_SHORT_WINDOW_REQUESTS", 60), ShortWindowSeconds: envInt("WARPBOX_SHORT_WINDOW_SECONDS", 60), AnonymousStorageBackend: envString("WARPBOX_ANONYMOUS_STORAGE_BACKEND", "local"), UserStorageBackend: envString("WARPBOX_USER_STORAGE_BACKEND", "local"), }, } if cfg.BaseURL == "" { return Config{}, fmt.Errorf("WARPBOX_BASE_URL cannot be empty") } if cfg.MaxUploadSize <= 0 { return Config{}, fmt.Errorf("WARPBOX_MAX_UPLOAD_SIZE_MB must be positive") } if !validUnlimitedMegabyteLimit(cfg.DefaultSettings.AnonymousMaxUploadMB) || !validUnlimitedMegabyteLimit(cfg.DefaultSettings.AnonymousDailyUploadMB) || !validUnlimitedMegabyteLimit(cfg.DefaultSettings.UserDailyUploadMB) || cfg.DefaultSettings.DefaultUserStorageMB <= 0 || cfg.DefaultSettings.UsageRetentionDays <= 0 || cfg.DefaultSettings.LocalStorageMaxGB <= 0 || cfg.DefaultSettings.AnonymousMaxDays <= 0 || cfg.DefaultSettings.UserMaxDays <= 0 || cfg.DefaultSettings.AnonymousDailyBoxes <= 0 || cfg.DefaultSettings.UserDailyBoxes <= 0 || cfg.DefaultSettings.AnonymousActiveBoxes <= 0 || cfg.DefaultSettings.UserActiveBoxes <= 0 || cfg.DefaultSettings.ShortWindowRequests <= 0 || cfg.DefaultSettings.ShortWindowSeconds <= 0 { return Config{}, fmt.Errorf("upload policy settings must be positive, with -1 allowed for upload MB limits") } return cfg, nil } func defaultPath(name string) string { candidates := []string{ filepath.Join("backend", name), name, } for _, candidate := range candidates { if info, err := os.Stat(candidate); err == nil && info.IsDir() { return candidate } } return filepath.Join("backend", name) } func envString(key, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } return fallback } func envDuration(key string, fallback time.Duration) time.Duration { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := time.ParseDuration(value) if err != nil { return fallback } return parsed } func envBool(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 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 envCSV(key string) []string { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return nil } parts := strings.Split(value, ",") values := make([]string, 0, len(parts)) for _, part := range parts { if trimmed := strings.TrimSpace(part); trimmed != "" { values = append(values, trimmed) } } return values } func envMegabytes(key string, fallback float64) int64 { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return megabytesToBytes(fallback) } parsed, err := parseMegabytes(value) if err != nil { return megabytesToBytes(fallback) } return parsed } func envMegabytesFloat(key string, fallback float64) float64 { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := parseMegabytesFloat(value) if err != nil { return fallback } return parsed } func envMegabytesLimitFloat(key string, fallback float64) float64 { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := parseMegabytesLimitFloat(value) if err != nil { return fallback } return parsed } func envGigabytesFloat(key string, fallback float64) float64 { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } normalized := strings.TrimSpace(value) normalized = strings.TrimSuffix(normalized, "GB") normalized = strings.TrimSuffix(normalized, "Gb") normalized = strings.TrimSuffix(normalized, "gb") normalized = strings.TrimSpace(normalized) parsed, err := strconv.ParseFloat(normalized, 64) if err != nil || parsed <= 0 { return fallback } return parsed } func parseMegabytes(value string) (int64, error) { sizeMB, err := parseMegabytesFloat(value) if err != nil { return 0, err } return megabytesToBytes(sizeMB), nil } func parseMegabytesFloat(value string) (float64, error) { normalized := strings.TrimSpace(value) normalized = strings.TrimSuffix(normalized, "MB") normalized = strings.TrimSuffix(normalized, "Mb") normalized = strings.TrimSuffix(normalized, "mb") normalized = strings.TrimSpace(normalized) sizeMB, err := strconv.ParseFloat(normalized, 64) if err != nil { return 0, fmt.Errorf("invalid megabyte value %q: %w", value, err) } if sizeMB <= 0 { return 0, fmt.Errorf("megabyte value must be positive") } return sizeMB, nil } func parseMegabytesLimitFloat(value string) (float64, error) { sizeMB, err := parseMegabytesFloatAllowNegativeOne(value) if err != nil { return 0, err } if !validUnlimitedMegabyteLimit(sizeMB) { return 0, fmt.Errorf("megabyte value must be positive or -1 for unlimited") } return sizeMB, nil } func parseMegabytesFloatAllowNegativeOne(value string) (float64, error) { normalized := strings.TrimSpace(value) normalized = strings.TrimSuffix(normalized, "MB") normalized = strings.TrimSuffix(normalized, "Mb") normalized = strings.TrimSuffix(normalized, "mb") normalized = strings.TrimSpace(normalized) sizeMB, err := strconv.ParseFloat(normalized, 64) if err != nil { return 0, fmt.Errorf("invalid megabyte value %q: %w", value, err) } return sizeMB, nil } func validUnlimitedMegabyteLimit(value float64) bool { return value > 0 || value == -1 } func megabytesToBytes(sizeMB float64) int64 { return int64(math.Round(sizeMB * 1024 * 1024)) }