package config import ( "errors" "fmt" "net/netip" "os" "path/filepath" "strconv" "strings" "time" "gopkg.in/yaml.v3" ) const ( defaultListenAddr = ":8080" defaultMaxUploadSize = "100MiB" defaultTTL = "1h" defaultRawCacheMaxSize = "200MiB" defaultRawCacheMaxEntries = 32 defaultDataDir = "./data" defaultCleanupInterval = "1m" defaultRateLimitEnabled = true defaultRequestsPerMinute = 30 defaultRateLimitBurst = 10 maxDefaultTTLLimit = 24 * time.Hour minCleanupInterval = 10 * time.Second ) 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 and rate limiting."` } type ServerConfig struct { ListenAddr string `yaml:"listen_addr" comment:"Address to bind the HTTP server to."` } 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:"-"` } 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 when true (trusted proxy only)."` RateLimit RateLimitConfig `yaml:"rate_limit" comment:"Per-client token bucket on POST /api/scratch."` AllowedPrefixes []netip.Prefix `yaml:"-"` } type RateLimitConfig struct { Enabled bool `yaml:"enabled" comment:"Enable per-client write-route rate limiting."` 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 Default() Config { return defaults() } func defaults() Config { return Config{ Server: ServerConfig{ ListenAddr: defaultListenAddr, }, Limits: LimitsConfig{ MaxUploadSize: defaultMaxUploadSize, DefaultTTL: defaultTTL, RawCacheMaxSize: defaultRawCacheMaxSize, RawCacheMaxEntries: defaultRawCacheMaxEntries, }, Storage: StorageConfig{ DataDir: defaultDataDir, CleanupInterval: defaultCleanupInterval, }, Security: SecurityConfig{ AllowedIPs: []string{ "127.0.0.1", }, RateLimit: RateLimitConfig{ Enabled: defaultRateLimitEnabled, RequestsPerMinute: defaultRequestsPerMinute, Burst: defaultRateLimitBurst, }, }, } } func (c *Config) Validate() error { if strings.TrimSpace(c.Server.ListenAddr) == "" { return errors.New("server.listen_addr is required") } 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") } if err := ensureWritableDir(c.Storage.DataDir); err != nil { return fmt.Errorf("storage.data_dir not writable: %w", err) } prefixes, err := parseAllowedPrefixes(c.Security.AllowedIPs) if err != nil { return fmt.Errorf("security.allowed_ips invalid: %w", err) } c.Security.AllowedPrefixes = prefixes if c.Security.RateLimit.RequestsPerMinute <= 0 { return errors.New("security.rate_limit.requests_per_minute must be > 0") } if c.Security.RateLimit.Burst <= 0 { return errors.New("security.rate_limit.burst must be > 0") } 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 { return 0, errors.New("calculated size must be > 0") } return size, nil }