package config import ( "fmt" "log/slog" "net/url" "os" "path" "path/filepath" "strconv" "strings" "time" ) const appName = "Update Server" type Config struct { AppName string HTTPAddr string BaseURL string DataDir string SQLitePath string ArtifactsDir string MigrationsDir string TemplatesDir string StaticDir string MaxUploadBytes int64 AdminEmail string AdminPassword string SessionCookieName string CSRFCookieName string SessionTTL time.Duration SecureCookies bool TrustProxyHeaders bool ReadTimeout time.Duration ReadHeaderTimeout time.Duration WriteTimeout time.Duration IdleTimeout time.Duration ShutdownTimeout time.Duration MaxHeaderBytes int LoginRateLimitPerMinute int LoginRateLimitBurst int ClientRateLimitPerMinute int ClientRateLimitBurst int LogLevel slog.Level } func Load() (Config, error) { baseURL := getenv("APP_BASE_URL", "http://127.0.0.1:8080") parsedBaseURL, err := validateBaseURL(baseURL) if err != nil { return Config{}, err } dataDir, err := filepath.Abs(getenv("DATA_DIR", "data-dev")) if err != nil { return Config{}, fmt.Errorf("resolve DATA_DIR: %w", err) } sqlitePath, err := resolvePath("SQLITE_PATH", filepath.Join(dataDir, "db.sqlite")) if err != nil { return Config{}, err } artifactsDir, err := resolvePath("ARTIFACTS_DIR", filepath.Join(dataDir, "artifacts")) if err != nil { return Config{}, err } migrationsDir, err := resolvePath("MIGRATIONS_DIR", "migrations") if err != nil { return Config{}, err } templatesDir, err := resolvePath("TEMPLATES_DIR", "web/templates") if err != nil { return Config{}, err } staticDir, err := resolvePath("STATIC_DIR", "web/static") if err != nil { return Config{}, err } adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL")) adminPassword := os.Getenv("ADMIN_PASSWORD") if adminEmail == "" && strings.TrimSpace(adminPassword) != "" { return Config{}, fmt.Errorf("ADMIN_PASSWORD requires ADMIN_EMAIL") } if adminEmail != "" && strings.TrimSpace(adminPassword) == "" { return Config{}, fmt.Errorf("ADMIN_EMAIL requires ADMIN_PASSWORD") } sessionCookieName := getenv("SESSION_COOKIE_NAME", "update_server_session") if strings.TrimSpace(sessionCookieName) == "" { return Config{}, fmt.Errorf("SESSION_COOKIE_NAME must not be empty") } csrfCookieName := getenv("CSRF_COOKIE_NAME", "update_server_csrf") if strings.TrimSpace(csrfCookieName) == "" { return Config{}, fmt.Errorf("CSRF_COOKIE_NAME must not be empty") } sessionTTL, err := parseDuration("SESSION_TTL", "24h") if err != nil { return Config{}, err } if sessionTTL <= 0 { return Config{}, fmt.Errorf("SESSION_TTL must be greater than zero") } maxUploadBytes, err := parseInt64("MAX_UPLOAD_BYTES", 1<<30) if err != nil { return Config{}, err } if maxUploadBytes <= 0 { return Config{}, fmt.Errorf("MAX_UPLOAD_BYTES must be greater than zero") } readTimeout, err := parseDuration("APP_READ_TIMEOUT", "10s") if err != nil { return Config{}, err } readHeaderTimeout, err := parseDuration("APP_READ_HEADER_TIMEOUT", "5s") if err != nil { return Config{}, err } writeTimeout, err := parseDuration("APP_WRITE_TIMEOUT", "60s") if err != nil { return Config{}, err } idleTimeout, err := parseDuration("APP_IDLE_TIMEOUT", "60s") if err != nil { return Config{}, err } shutdownTimeout, err := parseDuration("APP_SHUTDOWN_TIMEOUT", "10s") if err != nil { return Config{}, err } maxHeaderBytes, err := parseInt("APP_MAX_HEADER_BYTES", 1<<20) if err != nil { return Config{}, err } if maxHeaderBytes <= 0 { return Config{}, fmt.Errorf("APP_MAX_HEADER_BYTES must be greater than zero") } trustProxyHeaders, err := parseBool("TRUST_PROXY_HEADERS", false) if err != nil { return Config{}, err } loginRateLimitPerMinute, err := parseInt("APP_LOGIN_RATE_LIMIT_PER_MINUTE", 10) if err != nil { return Config{}, err } loginRateLimitBurst, err := parseInt("APP_LOGIN_RATE_LIMIT_BURST", 5) if err != nil { return Config{}, err } clientRateLimitPerMinute, err := parseInt("APP_CLIENT_RATE_LIMIT_PER_MINUTE", 120) if err != nil { return Config{}, err } clientRateLimitBurst, err := parseInt("APP_CLIENT_RATE_LIMIT_BURST", 60) if err != nil { return Config{}, err } for key, value := range map[string]int{ "APP_LOGIN_RATE_LIMIT_PER_MINUTE": loginRateLimitPerMinute, "APP_LOGIN_RATE_LIMIT_BURST": loginRateLimitBurst, "APP_CLIENT_RATE_LIMIT_PER_MINUTE": clientRateLimitPerMinute, "APP_CLIENT_RATE_LIMIT_BURST": clientRateLimitBurst, } { if value <= 0 { return Config{}, fmt.Errorf("%s must be greater than zero", key) } } logLevel, err := parseLogLevel(getenv("APP_LOG_LEVEL", "INFO")) if err != nil { return Config{}, err } if pathWithin(artifactsDir, staticDir) { return Config{}, fmt.Errorf("ARTIFACTS_DIR must be outside STATIC_DIR") } return Config{ AppName: appName, HTTPAddr: getenv("APP_ADDR", ":8080"), BaseURL: baseURL, DataDir: dataDir, SQLitePath: sqlitePath, ArtifactsDir: artifactsDir, MigrationsDir: migrationsDir, TemplatesDir: templatesDir, StaticDir: staticDir, MaxUploadBytes: maxUploadBytes, AdminEmail: adminEmail, AdminPassword: adminPassword, SessionCookieName: sessionCookieName, CSRFCookieName: csrfCookieName, SessionTTL: sessionTTL, SecureCookies: strings.EqualFold(parsedBaseURL.Scheme, "https"), TrustProxyHeaders: trustProxyHeaders, ReadTimeout: readTimeout, ReadHeaderTimeout: readHeaderTimeout, WriteTimeout: writeTimeout, IdleTimeout: idleTimeout, ShutdownTimeout: shutdownTimeout, MaxHeaderBytes: maxHeaderBytes, LoginRateLimitPerMinute: loginRateLimitPerMinute, LoginRateLimitBurst: loginRateLimitBurst, ClientRateLimitPerMinute: clientRateLimitPerMinute, ClientRateLimitBurst: clientRateLimitBurst, LogLevel: logLevel, }, nil } func getenv(key, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } return fallback } func validateBaseURL(raw string) (*url.URL, error) { parsed, err := url.Parse(raw) if err != nil { return nil, fmt.Errorf("parse APP_BASE_URL: %w", err) } if parsed.Scheme == "" || parsed.Host == "" { return nil, fmt.Errorf("APP_BASE_URL must include scheme and host, got %q", raw) } if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") { return nil, fmt.Errorf("APP_BASE_URL scheme must be http or https, got %q", parsed.Scheme) } return parsed, nil } func resolvePath(key, fallback string) (string, error) { path, err := filepath.Abs(getenv(key, fallback)) if err != nil { return "", fmt.Errorf("resolve %s: %w", key, err) } return path, nil } func parseDuration(key, fallback string) (time.Duration, error) { raw := getenv(key, fallback) value, err := time.ParseDuration(raw) if err != nil { return 0, fmt.Errorf("parse %s: %w", key, err) } return value, nil } func parseInt64(key string, fallback int64) (int64, error) { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return fallback, nil } value, err := strconv.ParseInt(raw, 10, 64) if err != nil { return 0, fmt.Errorf("parse %s: %w", key, err) } return value, nil } func parseInt(key string, fallback int) (int, error) { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return fallback, nil } value, err := strconv.Atoi(raw) if err != nil { return 0, fmt.Errorf("parse %s: %w", key, err) } return value, nil } func parseBool(key string, fallback bool) (bool, error) { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return fallback, nil } value, err := strconv.ParseBool(raw) if err != nil { return false, fmt.Errorf("parse %s: %w", key, err) } return value, nil } func parseLogLevel(raw string) (slog.Level, error) { switch strings.ToUpper(strings.TrimSpace(raw)) { case "DEBUG": return slog.LevelDebug, nil case "INFO": return slog.LevelInfo, nil case "WARN", "WARNING": return slog.LevelWarn, nil case "ERROR": return slog.LevelError, nil default: return 0, fmt.Errorf("APP_LOG_LEVEL must be one of DEBUG, INFO, WARN, ERROR") } } func pathWithin(candidate, parent string) bool { rel, err := filepath.Rel(filepath.Clean(parent), filepath.Clean(candidate)) if err != nil { return false } if rel == "." { return true } rel = filepath.ToSlash(rel) return rel != ".." && !strings.HasPrefix(rel, "../") && path.Clean(rel) != ".." }