initial commit
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user