package config import ( "crypto/rand" "encoding/base64" "errors" "fmt" "io/fs" "net/netip" "os" "path/filepath" "strconv" "strings" "time" "gopkg.in/yaml.v3" ) const ( defaultListenAddr = ":8080" defaultReadHeaderTimeout = "5s" defaultReadTimeout = "30s" defaultWriteTimeout = "30s" defaultIdleTimeout = "120s" defaultShutdownTimeout = "10s" defaultMaxHeaderBytes = 1 << 20 defaultAccessLogEnabled = true defaultAccessLogMaxSize = "100MiB" defaultAccessLogMaxBackups = 5 defaultMaxUploadSize = "100MiB" defaultTTL = "15m" defaultRawCacheMaxSize = "200MiB" defaultRawCacheMaxEntries = 32 defaultDataDir = "./data" defaultCleanupInterval = "1m" dataDirEncryptionKeyFilename = "metadata.key" defaultUIRateLimitEnabled = false defaultAPIReadRateLimitEnabled = false defaultAPIWriteRateLimitEnabled = true defaultUIRequestsPerMinute = 360 defaultUIRateLimitBurst = 120 defaultAPIReadRequestsPerMinute = 300 defaultAPIReadRateLimitBurst = 100 defaultAPIWriteRequestsPerMinute = 30 defaultAPIWriteRateLimitBurst = 10 defaultHSTSEnabled = true maxDefaultTTLLimit = 24 * time.Hour minCleanupInterval = 10 * time.Second envPrefix = "SCRATCHBOX_" ) type Config struct { Server ServerConfig `yaml:"server" comment:"HTTP listener settings."` Limits LimitsConfig `yaml:"limits" comment:"Upload, expiration, and raw cache defaults."` Storage StorageConfig `yaml:"storage" comment:"Filesystem storage location and cleanup cadence."` Security SecurityConfig `yaml:"security" comment:"Write-route IP restrictions, rate limiting, and HSTS."` } type ServerConfig struct { ListenAddr string `yaml:"listen_addr" comment:"Address to bind the HTTP server to."` ReadHeaderTimeout string `yaml:"read_header_timeout" comment:"Maximum duration for reading request headers (for slowloris protection). Must be > 0."` ReadTimeout string `yaml:"read_timeout" comment:"Maximum duration for reading the full request, including body. Must be > 0."` WriteTimeout string `yaml:"write_timeout" comment:"Maximum duration before timing out writes of a response. Must be > 0."` IdleTimeout string `yaml:"idle_timeout" comment:"Maximum amount of time to wait for the next request when keep-alives are enabled. Must be > 0."` ShutdownTimeout string `yaml:"shutdown_timeout" comment:"Graceful shutdown timeout for the HTTP server. Must be > 0."` MaxHeaderBytes int `yaml:"max_header_bytes" comment:"Maximum size of request headers in bytes. Must be > 0."` AccessLog AccessLogConfig `yaml:"access_log" comment:"Access log settings for all HTTP requests."` ReadHeaderTimeoutDur time.Duration `yaml:"-"` ReadTimeoutDur time.Duration `yaml:"-"` WriteTimeoutDur time.Duration `yaml:"-"` IdleTimeoutDur time.Duration `yaml:"-"` ShutdownTimeoutDur time.Duration `yaml:"-"` } type AccessLogConfig struct { Enabled bool `yaml:"enabled" comment:"Enable HTTP access logs."` FilePath string `yaml:"file_path" comment:"Optional file path for access logs. When set, logs are tee'd to stdout and this file."` MaxSize string `yaml:"max_size" comment:"Maximum size for access log file before rotation. Supports B, KB, MB, GB and KiB, MiB, GiB."` MaxBackups int `yaml:"max_backups" comment:"Number of rotated access log files to keep."` MaxSizeBytes int64 `yaml:"-"` } type LimitsConfig struct { MaxUploadSize string `yaml:"max_upload_size" comment:"Maximum upload request body size. Supports B, KB, MB, GB and KiB, MiB, GiB (case-insensitive). Defaults to bytes when unit is omitted (example: 1048576)."` DefaultTTL string `yaml:"default_ttl" comment:"Default expiration for new scratches. Must be > 0 and <= 24h."` RawCacheMaxSize string `yaml:"raw_cache_max_size" comment:"Maximum total decompressed bytes cached in memory for /api/raw range requests. Supports B, KB, MB, GB and KiB, MiB, GiB. Set this above limits.max_upload_size based on your expected concurrent large downloads."` RawCacheMaxEntries int `yaml:"raw_cache_max_entries" comment:"Maximum number of decompressed /api/raw entries retained in memory cache. Use this as a secondary cap alongside raw_cache_max_size."` MaxUploadSizeBytes int64 `yaml:"-"` DefaultTTLDuration time.Duration `yaml:"-"` RawCacheMaxBytes int64 `yaml:"-"` } type StorageConfig struct { DataDir string `yaml:"data_dir" comment:"Directory where scratch files and metadata are stored."` CleanupInterval string `yaml:"cleanup_interval" comment:"How often expired scratches are deleted (minimum 10s)."` CleanupIntervalParsed time.Duration `yaml:"-"` EncryptionKeyBytes []byte `yaml:"-"` } type SecurityConfig struct { AllowedIPs []string `yaml:"allowed_ips" comment:"Allowlist for POST /api/scratch as IPs or CIDRs (examples: 127.0.0.1, 192.168.1.0/24). Keep 127.0.0.1 for local access; add trusted ranges as needed. Empty means unrestricted."` TrustProxyHeaders bool `yaml:"trust_proxy_headers" comment:"Honor X-Forwarded-For / X-Real-IP only when request source IP matches security.trusted_proxy_ips."` TrustedProxyIPs []string `yaml:"trusted_proxy_ips" comment:"IPs/CIDRs of reverse proxies allowed to supply forwarded client IP headers. Required when trust_proxy_headers is true. Must be the *immediate* proxies only (never 0/0 or untrusted ranges)."` RateLimitUI RateLimitConfig `yaml:"rate_limit_ui" comment:"Per-client token bucket on UI routes (/, /u, /s/{id})."` RateLimitAPIRead RateLimitConfig `yaml:"rate_limit_api_read" comment:"Per-client token bucket on API read routes (GET /api/config, GET /api/scratch/{id}, GET /api/raw/{id})."` RateLimitAPIWrite RateLimitConfig `yaml:"rate_limit_api_write" comment:"Per-client token bucket on API write route (POST /api/scratch)."` HSTSEnabled bool `yaml:"hsts_enabled" comment:"Enable Strict-Transport-Security (HSTS) header. Recommended when behind a TLS-terminating reverse proxy for public deployments (default: true). Set to false for local-only HTTP use without proxies."` AllowedPrefixes []netip.Prefix `yaml:"-"` TrustedProxyPrefixes []netip.Prefix `yaml:"-"` } type RateLimitConfig struct { Enabled bool `yaml:"enabled" comment:"Enable per-client rate limiting for write and scratch read routes."` RequestsPerMinute int `yaml:"requests_per_minute" comment:"Steady refill rate per client IP."` Burst int `yaml:"burst" comment:"Maximum immediate burst capacity per client IP."` } func Load(path string) (Config, error) { cfg := defaults() if path != "" { b, err := os.ReadFile(path) if err != nil { return Config{}, fmt.Errorf("read config: %w", err) } if err := yaml.Unmarshal(b, &cfg); err != nil { return Config{}, fmt.Errorf("parse config yaml: %w", err) } } if err := cfg.Validate(); err != nil { return Config{}, err } return cfg, nil } func LoadFromEnv() (Config, error) { cfg := defaults() if err := cfg.applyEnvOverrides(envPrefix); err != nil { return Config{}, err } if err := cfg.Validate(); err != nil { return Config{}, err } return cfg, nil } func Default() Config { return defaults() } func defaults() Config { return Config{ Server: ServerConfig{ ListenAddr: defaultListenAddr, ReadHeaderTimeout: defaultReadHeaderTimeout, ReadTimeout: defaultReadTimeout, WriteTimeout: defaultWriteTimeout, IdleTimeout: defaultIdleTimeout, ShutdownTimeout: defaultShutdownTimeout, MaxHeaderBytes: defaultMaxHeaderBytes, AccessLog: AccessLogConfig{ Enabled: defaultAccessLogEnabled, MaxSize: defaultAccessLogMaxSize, MaxBackups: defaultAccessLogMaxBackups, }, }, Limits: LimitsConfig{ MaxUploadSize: defaultMaxUploadSize, DefaultTTL: defaultTTL, RawCacheMaxSize: defaultRawCacheMaxSize, RawCacheMaxEntries: defaultRawCacheMaxEntries, }, Storage: StorageConfig{ DataDir: defaultDataDir, CleanupInterval: defaultCleanupInterval, }, Security: SecurityConfig{ AllowedIPs: []string{ "127.0.0.1", }, RateLimitUI: RateLimitConfig{ Enabled: defaultUIRateLimitEnabled, RequestsPerMinute: defaultUIRequestsPerMinute, Burst: defaultUIRateLimitBurst, }, RateLimitAPIRead: RateLimitConfig{ Enabled: defaultAPIReadRateLimitEnabled, RequestsPerMinute: defaultAPIReadRequestsPerMinute, Burst: defaultAPIReadRateLimitBurst, }, RateLimitAPIWrite: RateLimitConfig{ Enabled: defaultAPIWriteRateLimitEnabled, RequestsPerMinute: defaultAPIWriteRequestsPerMinute, Burst: defaultAPIWriteRateLimitBurst, }, HSTSEnabled: defaultHSTSEnabled, }, } } func (c *Config) Validate() error { if strings.TrimSpace(c.Server.ListenAddr) == "" { return errors.New("server.listen_addr is required") } readHeaderTimeout, err := time.ParseDuration(strings.TrimSpace(c.Server.ReadHeaderTimeout)) if err != nil { return fmt.Errorf("server.read_header_timeout invalid: %w", err) } if readHeaderTimeout <= 0 { return errors.New("server.read_header_timeout must be > 0") } c.Server.ReadHeaderTimeoutDur = readHeaderTimeout readTimeout, err := time.ParseDuration(strings.TrimSpace(c.Server.ReadTimeout)) if err != nil { return fmt.Errorf("server.read_timeout invalid: %w", err) } if readTimeout <= 0 { return errors.New("server.read_timeout must be > 0") } c.Server.ReadTimeoutDur = readTimeout writeTimeout, err := time.ParseDuration(strings.TrimSpace(c.Server.WriteTimeout)) if err != nil { return fmt.Errorf("server.write_timeout invalid: %w", err) } if writeTimeout <= 0 { return errors.New("server.write_timeout must be > 0") } c.Server.WriteTimeoutDur = writeTimeout idleTimeout, err := time.ParseDuration(strings.TrimSpace(c.Server.IdleTimeout)) if err != nil { return fmt.Errorf("server.idle_timeout invalid: %w", err) } if idleTimeout <= 0 { return errors.New("server.idle_timeout must be > 0") } c.Server.IdleTimeoutDur = idleTimeout shutdownTimeout, err := time.ParseDuration(strings.TrimSpace(c.Server.ShutdownTimeout)) if err != nil { return fmt.Errorf("server.shutdown_timeout invalid: %w", err) } if shutdownTimeout <= 0 { return errors.New("server.shutdown_timeout must be > 0") } c.Server.ShutdownTimeoutDur = shutdownTimeout if c.Server.MaxHeaderBytes <= 0 { return errors.New("server.max_header_bytes must be > 0") } accessLogMaxSizeBytes, err := parseHumanSize(c.Server.AccessLog.MaxSize) if err != nil { return fmt.Errorf("server.access_log.max_size invalid: %w", err) } c.Server.AccessLog.MaxSizeBytes = accessLogMaxSizeBytes if c.Server.AccessLog.MaxBackups <= 0 { return errors.New("server.access_log.max_backups must be > 0") } sizeBytes, err := parseHumanSize(c.Limits.MaxUploadSize) if err != nil { return fmt.Errorf("limits.max_upload_size invalid: %w", err) } c.Limits.MaxUploadSizeBytes = sizeBytes ttl, err := time.ParseDuration(strings.TrimSpace(c.Limits.DefaultTTL)) if err != nil { return fmt.Errorf("limits.default_ttl invalid: %w", err) } if ttl <= 0 || ttl > maxDefaultTTLLimit { return fmt.Errorf("limits.default_ttl must be in (0, %s]", maxDefaultTTLLimit) } c.Limits.DefaultTTLDuration = ttl rawCacheBytes, err := parseHumanSize(c.Limits.RawCacheMaxSize) if err != nil { return fmt.Errorf("limits.raw_cache_max_size invalid: %w", err) } c.Limits.RawCacheMaxBytes = rawCacheBytes if c.Limits.RawCacheMaxEntries <= 0 { return errors.New("limits.raw_cache_max_entries must be > 0") } cleanupInterval, err := time.ParseDuration(strings.TrimSpace(c.Storage.CleanupInterval)) if err != nil { return fmt.Errorf("storage.cleanup_interval invalid: %w", err) } if cleanupInterval < minCleanupInterval { return fmt.Errorf("storage.cleanup_interval must be at least %s", minCleanupInterval) } c.Storage.CleanupIntervalParsed = cleanupInterval if strings.TrimSpace(c.Storage.DataDir) == "" { return errors.New("storage.data_dir is required") } // NOTE: directory creation + encryption key provisioning (with auto-gen on empty dir) // moved to PrepareDataDir() to keep Validate() free of filesystem side effects. // Callers (server startup, tests) must invoke PrepareDataDir after successful Validate/Load // if they need EncryptionKeyBytes populated and data dir ensured. prefixes, err := parseAllowedPrefixes(c.Security.AllowedIPs) if err != nil { return fmt.Errorf("security.allowed_ips invalid: %w", err) } c.Security.AllowedPrefixes = prefixes trustedProxyPrefixes, err := parseAllowedPrefixes(c.Security.TrustedProxyIPs) if err != nil { return fmt.Errorf("security.trusted_proxy_ips invalid: %w", err) } c.Security.TrustedProxyPrefixes = trustedProxyPrefixes if c.Security.TrustProxyHeaders && len(c.Security.TrustedProxyPrefixes) == 0 { return errors.New("security.trusted_proxy_ips is required when security.trust_proxy_headers is true") } if c.Security.RateLimitUI.RequestsPerMinute <= 0 { return errors.New("security.rate_limit_ui.requests_per_minute must be > 0") } if c.Security.RateLimitUI.Burst <= 0 { return errors.New("security.rate_limit_ui.burst must be > 0") } if c.Security.RateLimitAPIRead.RequestsPerMinute <= 0 { return errors.New("security.rate_limit_api_read.requests_per_minute must be > 0") } if c.Security.RateLimitAPIRead.Burst <= 0 { return errors.New("security.rate_limit_api_read.burst must be > 0") } if c.Security.RateLimitAPIWrite.RequestsPerMinute <= 0 { return errors.New("security.rate_limit_api_write.requests_per_minute must be > 0") } if c.Security.RateLimitAPIWrite.Burst <= 0 { return errors.New("security.rate_limit_api_write.burst must be > 0") } return nil } // PrepareDataDir performs the filesystem side effects that were previously inside Validate: // ensures the data dir is creatable/writable (via probe), and ensures the encryption key // file exists at data_dir/metadata.key (auto-generating a fresh one only for a truly empty dir). // It populates Storage.EncryptionKeyBytes. This keeps Validate pure (checks + duration/size // parsing only). Must be called after Load/Validate (or on a manually built Config) before // constructing a FilesystemStore that needs the key. Idempotent for subsequent calls if key // already present. func (c *Config) PrepareDataDir() error { if strings.TrimSpace(c.Storage.DataDir) == "" { return errors.New("storage.data_dir is required") } if err := ensureWritableDir(c.Storage.DataDir); err != nil { return fmt.Errorf("storage.data_dir not writable: %w", err) } encKey, err := ensureEncryptionKeyInDataDir(c.Storage.DataDir) if err != nil { return fmt.Errorf("storage encryption key setup failed: %w", err) } if len(encKey) != 32 { return fmt.Errorf("storage encryption key must decode to 32 bytes, got %d", len(encKey)) } c.Storage.EncryptionKeyBytes = encKey return nil } func ensureWritableDir(dir string) error { if err := os.MkdirAll(dir, 0o755); err != nil { return err } probe := filepath.Join(dir, ".write_probe") if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil { return err } return os.Remove(probe) } func parseAllowedPrefixes(values []string) ([]netip.Prefix, error) { if len(values) == 0 { return nil, nil } out := make([]netip.Prefix, 0, len(values)) for _, value := range values { item := strings.TrimSpace(value) if item == "" { continue } if strings.Contains(item, "/") { prefix, err := netip.ParsePrefix(item) if err != nil { return nil, fmt.Errorf("parse cidr %q: %w", item, err) } out = append(out, prefix) continue } ip, err := netip.ParseAddr(item) if err != nil { return nil, fmt.Errorf("parse ip %q: %w", item, err) } out = append(out, netip.PrefixFrom(ip, ip.BitLen())) } return out, nil } func parseHumanSize(raw string) (int64, error) { value := strings.TrimSpace(strings.ToUpper(raw)) if value == "" { return 0, errors.New("size is empty") } idx := 0 for idx < len(value) && (value[idx] == '.' || (value[idx] >= '0' && value[idx] <= '9')) { idx++ } if idx == 0 { return 0, fmt.Errorf("size %q does not start with a number", raw) } numPart := value[:idx] unitPart := strings.TrimSpace(value[idx:]) if unitPart == "" { unitPart = "B" } number, err := strconv.ParseFloat(numPart, 64) if err != nil { return 0, fmt.Errorf("parse number %q: %w", numPart, err) } if number <= 0 { return 0, errors.New("size must be > 0") } multipliers := map[string]float64{ "B": 1, "KB": 1_000, "MB": 1_000_000, "GB": 1_000_000_000, "KIB": 1024, "MIB": 1024 * 1024, "GIB": 1024 * 1024 * 1024, } multiplier, ok := multipliers[unitPart] if !ok { return 0, fmt.Errorf("unsupported unit %q", unitPart) } size := int64(number * multiplier) if size <= 0 { if number > 0 { // overflow or truncation from huge float (e.g. 1e20 * GiB); guard before // passing surprising/negative limit downstream. return 0, errors.New("size too large") } return 0, errors.New("calculated size must be > 0") } return size, nil } func GenerateEncryptionKey() (string, error) { raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", fmt.Errorf("generate encryption key bytes: %w", err) } return base64.StdEncoding.EncodeToString(raw), nil } func ensureEncryptionKeyInDataDir(dataDir string) ([]byte, error) { keyPath := filepath.Join(dataDir, dataDirEncryptionKeyFilename) keyRaw, err := os.ReadFile(keyPath) if err != nil { if !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("read %q: %w", keyPath, err) } hasFiles, checkErr := dataDirContainsFiles(dataDir, keyPath) if checkErr != nil { return nil, fmt.Errorf("scan %q for existing files: %w", dataDir, checkErr) } if hasFiles { return nil, fmt.Errorf("refusing to generate missing %q because %q contains files; restore the original key file to avoid data corruption", dataDirEncryptionKeyFilename, dataDir) } generated, genErr := GenerateEncryptionKey() if genErr != nil { return nil, fmt.Errorf("generate key: %w", genErr) } if writeErr := os.WriteFile(keyPath, []byte(generated+"\n"), 0o600); writeErr != nil { return nil, fmt.Errorf("write %q: %w", keyPath, writeErr) } return decodeEncryptionKey(generated) } encKey, err := decodeEncryptionKey(strings.TrimSpace(string(keyRaw))) if err != nil { return nil, fmt.Errorf("%q invalid: %w", keyPath, err) } return encKey, nil } func dataDirContainsFiles(dataDir string, keyPath string) (bool, error) { keyPath = filepath.Clean(keyPath) foundFile := false err := filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if d.IsDir() { return nil } if filepath.Clean(path) == keyPath { return nil } foundFile = true return fs.SkipAll }) if err != nil && !errors.Is(err, fs.SkipAll) { return false, err } return foundFile, nil } func decodeEncryptionKey(raw string) ([]byte, error) { if raw == "" { return nil, errors.New("key is empty") } encKey, err := base64.StdEncoding.DecodeString(raw) if err != nil { return nil, fmt.Errorf("invalid base64: %w", err) } if len(encKey) != 32 { return nil, fmt.Errorf("must decode to 32 bytes, got %d", len(encKey)) } return encKey, nil } func (c *Config) applyEnvOverrides(prefix string) error { if err := applyStringEnv(prefix+"SERVER_LISTEN_ADDR", &c.Server.ListenAddr); err != nil { return err } if err := applyStringEnv(prefix+"SERVER_READ_HEADER_TIMEOUT", &c.Server.ReadHeaderTimeout); err != nil { return err } if err := applyStringEnv(prefix+"SERVER_READ_TIMEOUT", &c.Server.ReadTimeout); err != nil { return err } if err := applyStringEnv(prefix+"SERVER_WRITE_TIMEOUT", &c.Server.WriteTimeout); err != nil { return err } if err := applyStringEnv(prefix+"SERVER_IDLE_TIMEOUT", &c.Server.IdleTimeout); err != nil { return err } if err := applyStringEnv(prefix+"SERVER_SHUTDOWN_TIMEOUT", &c.Server.ShutdownTimeout); err != nil { return err } if err := applyIntEnv(prefix+"SERVER_MAX_HEADER_BYTES", &c.Server.MaxHeaderBytes); err != nil { return err } if err := applyBoolEnv(prefix+"SERVER_ACCESS_LOG_ENABLED", &c.Server.AccessLog.Enabled); err != nil { return err } if err := applyStringEnv(prefix+"SERVER_ACCESS_LOG_FILE_PATH", &c.Server.AccessLog.FilePath); err != nil { return err } if err := applyStringEnv(prefix+"SERVER_ACCESS_LOG_MAX_SIZE", &c.Server.AccessLog.MaxSize); err != nil { return err } if err := applyIntEnv(prefix+"SERVER_ACCESS_LOG_MAX_BACKUPS", &c.Server.AccessLog.MaxBackups); err != nil { return err } if err := applyStringEnv(prefix+"LIMITS_MAX_UPLOAD_SIZE", &c.Limits.MaxUploadSize); err != nil { return err } if err := applyStringEnv(prefix+"LIMITS_DEFAULT_TTL", &c.Limits.DefaultTTL); err != nil { return err } if err := applyStringEnv(prefix+"LIMITS_RAW_CACHE_MAX_SIZE", &c.Limits.RawCacheMaxSize); err != nil { return err } if err := applyIntEnv(prefix+"LIMITS_RAW_CACHE_MAX_ENTRIES", &c.Limits.RawCacheMaxEntries); err != nil { return err } if err := applyStringEnv(prefix+"STORAGE_DATA_DIR", &c.Storage.DataDir); err != nil { return err } if err := applyStringEnv(prefix+"STORAGE_CLEANUP_INTERVAL", &c.Storage.CleanupInterval); err != nil { return err } applyListEnv(prefix+"SECURITY_ALLOWED_IPS", &c.Security.AllowedIPs) if err := applyBoolEnv(prefix+"SECURITY_TRUST_PROXY_HEADERS", &c.Security.TrustProxyHeaders); err != nil { return err } applyListEnv(prefix+"SECURITY_TRUSTED_PROXY_IPS", &c.Security.TrustedProxyIPs) if err := applyBoolEnv(prefix+"SECURITY_RATE_LIMIT_UI_ENABLED", &c.Security.RateLimitUI.Enabled); err != nil { return err } if err := applyIntEnv(prefix+"SECURITY_RATE_LIMIT_UI_REQUESTS_PER_MINUTE", &c.Security.RateLimitUI.RequestsPerMinute); err != nil { return err } if err := applyIntEnv(prefix+"SECURITY_RATE_LIMIT_UI_BURST", &c.Security.RateLimitUI.Burst); err != nil { return err } if err := applyBoolEnv(prefix+"SECURITY_RATE_LIMIT_API_READ_ENABLED", &c.Security.RateLimitAPIRead.Enabled); err != nil { return err } if err := applyIntEnv(prefix+"SECURITY_RATE_LIMIT_API_READ_REQUESTS_PER_MINUTE", &c.Security.RateLimitAPIRead.RequestsPerMinute); err != nil { return err } if err := applyIntEnv(prefix+"SECURITY_RATE_LIMIT_API_READ_BURST", &c.Security.RateLimitAPIRead.Burst); err != nil { return err } if err := applyBoolEnv(prefix+"SECURITY_RATE_LIMIT_API_WRITE_ENABLED", &c.Security.RateLimitAPIWrite.Enabled); err != nil { return err } if err := applyIntEnv(prefix+"SECURITY_RATE_LIMIT_API_WRITE_REQUESTS_PER_MINUTE", &c.Security.RateLimitAPIWrite.RequestsPerMinute); err != nil { return err } if err := applyIntEnv(prefix+"SECURITY_RATE_LIMIT_API_WRITE_BURST", &c.Security.RateLimitAPIWrite.Burst); err != nil { return err } if err := applyBoolEnv(prefix+"SECURITY_HSTS_ENABLED", &c.Security.HSTSEnabled); err != nil { return err } return nil } func applyStringEnv(name string, target *string) error { value, ok := os.LookupEnv(name) if !ok { return nil } *target = strings.TrimSpace(value) return nil } func applyBoolEnv(name string, target *bool) error { value, ok := os.LookupEnv(name) if !ok { return nil } parsed, err := strconv.ParseBool(strings.TrimSpace(value)) if err != nil { return fmt.Errorf("%s invalid bool: %w", name, err) } *target = parsed return nil } func applyIntEnv(name string, target *int) error { value, ok := os.LookupEnv(name) if !ok { return nil } parsed, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil { return fmt.Errorf("%s invalid int: %w", name, err) } *target = parsed return nil } func applyListEnv(name string, target *[]string) { value, ok := os.LookupEnv(name) if !ok { return } if strings.TrimSpace(value) == "" { *target = nil return } parts := strings.Split(value, ",") out := make([]string, 0, len(parts)) for _, part := range parts { item := strings.TrimSpace(part) if item == "" { continue } out = append(out, item) } *target = out }