package config import ( "errors" "fmt" "net/netip" "os" "path/filepath" "strconv" "strings" "time" "gopkg.in/yaml.v3" ) const ( defaultListenAddr = ":8080" defaultMaxUploadSize = "100MB" defaultTTL = "1h" 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"` Limits LimitsConfig `yaml:"limits"` Storage StorageConfig `yaml:"storage"` Security SecurityConfig `yaml:"security"` } type ServerConfig struct { ListenAddr string `yaml:"listen_addr"` } type LimitsConfig struct { MaxUploadSize string `yaml:"max_upload_size"` DefaultTTL string `yaml:"default_ttl"` MaxUploadSizeBytes int64 `yaml:"-"` DefaultTTLDuration time.Duration `yaml:"-"` } type StorageConfig struct { DataDir string `yaml:"data_dir"` CleanupInterval string `yaml:"cleanup_interval"` CleanupIntervalParsed time.Duration `yaml:"-"` } type SecurityConfig struct { AllowedIPs []string `yaml:"allowed_ips"` TrustProxyHeaders bool `yaml:"trust_proxy_headers"` RateLimit RateLimitConfig `yaml:"rate_limit"` AllowedPrefixes []netip.Prefix `yaml:"-"` } type RateLimitConfig struct { Enabled bool `yaml:"enabled"` RequestsPerMinute int `yaml:"requests_per_minute"` Burst int `yaml:"burst"` } 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 defaults() Config { return Config{ Server: ServerConfig{ ListenAddr: defaultListenAddr, }, Limits: LimitsConfig{ MaxUploadSize: defaultMaxUploadSize, DefaultTTL: defaultTTL, }, Storage: StorageConfig{ DataDir: defaultDataDir, CleanupInterval: defaultCleanupInterval, }, Security: SecurityConfig{ 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 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, "TB": 1_000_000_000_000, "KIB": 1024, "MIB": 1024 * 1024, "GIB": 1024 * 1024 * 1024, "TIB": 1024 * 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 }