// SPDX-License-Identifier: AGPL-3.0-or-later // Package config loads application configuration from environment variables. package config import ( "log/slog" "net" "net/url" "os" "strconv" "strings" "time" ) // Config holds all application configuration. type Config struct { // Database DBPath string // Server Listen string BasePath string ExternalURL string TrustedProxies []*net.IPNet // Uploads UploadDir string MaxUploadSize int64 // Security SessionLifetime time.Duration Argon2Memory uint32 Argon2Time uint32 Argon2Parallelism uint8 RateLimit int // Admin AdminUsername string AdminPassword string // Site SiteName string DevMode bool } // Load reads configuration from environment variables with sensible defaults. func Load() *Config { cfg := &Config{ DBPath: envOr("FAVORITTER_DB_PATH", "./data/favoritter.db"), Listen: envOr("FAVORITTER_LISTEN", ":8080"), BasePath: envOr("FAVORITTER_BASE_PATH", "/"), ExternalURL: os.Getenv("FAVORITTER_EXTERNAL_URL"), UploadDir: envOr("FAVORITTER_UPLOAD_DIR", "./data/uploads"), MaxUploadSize: envInt64("FAVORITTER_MAX_UPLOAD_SIZE", 10<<20), // 10MB SessionLifetime: envDuration("FAVORITTER_SESSION_LIFETIME", 720*time.Hour), Argon2Memory: uint32(envInt("FAVORITTER_ARGON2_MEMORY", 65536)), Argon2Time: uint32(envInt("FAVORITTER_ARGON2_TIME", 3)), Argon2Parallelism: uint8(envInt("FAVORITTER_ARGON2_PARALLELISM", 2)), RateLimit: envInt("FAVORITTER_RATE_LIMIT", 60), AdminUsername: os.Getenv("FAVORITTER_ADMIN_USERNAME"), AdminPassword: os.Getenv("FAVORITTER_ADMIN_PASSWORD"), SiteName: envOr("FAVORITTER_SITE_NAME", "Favoritter"), DevMode: envBool("FAVORITTER_DEV_MODE", false), } // Normalize base path: ensure it starts with / and doesn't end with /. cfg.BasePath = normalizePath(cfg.BasePath) // Normalize external URL: strip trailing slash. cfg.ExternalURL = strings.TrimRight(cfg.ExternalURL, "/") // Parse trusted proxies. cfg.TrustedProxies = parseCIDRs(envOr("FAVORITTER_TRUSTED_PROXIES", "127.0.0.1")) return cfg } // BaseURL returns the external base URL for generating absolute URLs. // If EXTERNAL_URL is set, it's used directly. Otherwise, falls back to // constructing from the request (caller should pass the Host header). func (c *Config) BaseURL(requestHost string) string { if c.ExternalURL != "" { return c.ExternalURL } // Fallback: construct from request host. // Assume HTTPS unless we know otherwise. return "https://" + requestHost + c.BasePath } // IsExternalURLConfigured returns true if an explicit external URL was set. func (c *Config) IsExternalURLConfigured() bool { return c.ExternalURL != "" } // ExternalHostname returns the hostname from the external URL, or empty string. func (c *Config) ExternalHostname() string { if c.ExternalURL == "" { return "" } u, err := url.Parse(c.ExternalURL) if err != nil { return "" } return u.Hostname() } func normalizePath(p string) string { if p == "" || p == "/" { return "" } if !strings.HasPrefix(p, "/") { p = "/" + p } return strings.TrimRight(p, "/") } func parseCIDRs(s string) []*net.IPNet { var nets []*net.IPNet for _, part := range strings.Split(s, ",") { part = strings.TrimSpace(part) if part == "" { continue } // If no CIDR notation, add /32 for IPv4 or /128 for IPv6. if !strings.Contains(part, "/") { ip := net.ParseIP(part) if ip == nil { slog.Warn("invalid trusted proxy IP, skipping", "ip", part) continue } if ip.To4() != nil { part += "/32" } else { part += "/128" } } _, ipNet, err := net.ParseCIDR(part) if err != nil { slog.Warn("invalid trusted proxy CIDR, skipping", "cidr", part, "error", err) continue } nets = append(nets, ipNet) } return nets } func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } func envInt(key string, fallback int) int { v := os.Getenv(key) if v == "" { return fallback } n, err := strconv.Atoi(v) if err != nil { slog.Warn("invalid integer env var, using default", "key", key, "value", v, "default", fallback) return fallback } return n } func envInt64(key string, fallback int64) int64 { v := os.Getenv(key) if v == "" { return fallback } n, err := strconv.ParseInt(v, 10, 64) if err != nil { slog.Warn("invalid int64 env var, using default", "key", key, "value", v, "default", fallback) return fallback } return n } func envDuration(key string, fallback time.Duration) time.Duration { v := os.Getenv(key) if v == "" { return fallback } d, err := time.ParseDuration(v) if err != nil { slog.Warn("invalid duration env var, using default", "key", key, "value", v, "default", fallback) return fallback } return d } func envBool(key string, fallback bool) bool { v := os.Getenv(key) if v == "" { return fallback } b, err := strconv.ParseBool(v) if err != nil { slog.Warn("invalid bool env var, using default", "key", key, "value", v, "default", fallback) return fallback } return b }