// Package config provides application configuration using smartconfig. package config import ( "fmt" "log/slog" "os" "path/filepath" "strings" "git.eeqj.de/sneak/smartconfig" "go.uber.org/fx" "sneak.berlin/go/pixa/internal/globals" "sneak.berlin/go/pixa/internal/logger" ) // Default configuration values. const ( DefaultPort = 8080 DefaultStateDir = "/var/lib/pixa" DefaultUpstreamConnectionsPerHost = 20 ) // Params defines dependencies for Config. type Params struct { fx.In Globals *globals.Globals Logger *logger.Logger } // Config holds application configuration values. type Config struct { Debug bool MaintenanceMode bool MetricsPassword string MetricsUsername string Port int SentryDSN string StateDir string DBURL string // Image proxy settings SigningKey string // HMAC signing key for URL signatures WhitelistHosts []string // Hosts that don't require signatures AllowHTTP bool // Allow non-TLS upstream (testing only) UpstreamConnectionsPerHost int // Max concurrent connections per upstream host } // New creates a new Config instance by loading configuration from file. func New(_ fx.Lifecycle, params Params) (*Config, error) { log := params.Logger.Get() name := params.Globals.Appname sc, err := loadConfigFile(log, name) if err != nil { return nil, err } if sc == nil { log.Info("no config file found, using defaults") } c := &Config{ Debug: getBool(sc, "debug", false), MaintenanceMode: getBool(sc, "maintenance_mode", false), Port: getInt(sc, "port", DefaultPort), StateDir: getString(sc, "state_dir", DefaultStateDir), SentryDSN: getString(sc, "sentry_dsn", ""), MetricsUsername: getString(sc, "metrics.username", ""), MetricsPassword: getString(sc, "metrics.password", ""), SigningKey: getString(sc, "signing_key", ""), WhitelistHosts: getStringSlice(sc, "whitelist_hosts"), AllowHTTP: getBool(sc, "allow_http", false), UpstreamConnectionsPerHost: getInt(sc, "upstream_connections_per_host", DefaultUpstreamConnectionsPerHost), } // Build DBURL from StateDir if not explicitly set c.DBURL = getString(sc, "db_url", "") if c.DBURL == "" { c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir) } if c.Debug { params.Logger.EnableDebugLogging() } // Validate required configuration if err := c.validate(); err != nil { return nil, err } return c, nil } // validate checks that all required configuration values are set. func (c *Config) validate() error { if c.SigningKey == "" { return fmt.Errorf("signing_key is required") } // Minimum key length for security (32 bytes = 256 bits) const minKeyLength = 32 if len(c.SigningKey) < minKeyLength { return fmt.Errorf("signing_key must be at least %d characters", minKeyLength) } return nil } // loadConfigFile loads configuration from PIXA_CONFIG_PATH env var or standard locations. func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, error) { // Check for explicit config path from environment if envPath := os.Getenv("PIXA_CONFIG_PATH"); envPath != "" { sc, err := smartconfig.NewFromConfigPath(envPath) if err != nil { return nil, fmt.Errorf("failed to load config from %s: %w", envPath, err) } log.Info("loaded config file", "path", envPath) return sc, nil } // Try loading config from standard locations configPaths := []string{ fmt.Sprintf("/etc/%s/config.yml", appName), fmt.Sprintf("/etc/%s/config.yaml", appName), filepath.Join(os.Getenv("HOME"), ".config", appName, "config.yml"), filepath.Join(os.Getenv("HOME"), ".config", appName, "config.yaml"), "config.yml", "config.yaml", } for _, path := range configPaths { if _, statErr := os.Stat(path); statErr == nil { sc, err := smartconfig.NewFromConfigPath(path) if err != nil { log.Warn("failed to parse config file", "path", path, "error", err) continue } log.Info("loaded config file", "path", path) return sc, nil } } return nil, nil //nolint:nilnil // nil config is valid (use defaults) } func getString(sc *smartconfig.Config, key, defaultVal string) string { if sc == nil { return defaultVal } val, err := sc.GetString(key) if err != nil { return defaultVal } return val } func getInt(sc *smartconfig.Config, key string, defaultVal int) int { if sc == nil { return defaultVal } val, err := sc.GetInt(key) if err != nil { return defaultVal } return val } func getBool(sc *smartconfig.Config, key string, defaultVal bool) bool { if sc == nil { return defaultVal } val, err := sc.GetBool(key) if err != nil { return defaultVal } return val } func getStringSlice(sc *smartconfig.Config, key string) []string { if sc == nil { return nil } val, ok := sc.Get(key) if !ok || val == nil { return nil } // Handle YAML list format if slice, ok := val.([]interface{}); ok { result := make([]string, 0, len(slice)) for _, item := range slice { if str, ok := item.(string); ok { trimmed := strings.TrimSpace(str) if trimmed != "" { result = append(result, trimmed) } } } return result } // Fall back to comma-separated string for backwards compatibility if str, ok := val.(string); ok && str != "" { parts := strings.Split(str, ",") result := make([]string, 0, len(parts)) for _, part := range parts { trimmed := strings.TrimSpace(part) if trimmed != "" { result = append(result, trimmed) } } return result } return nil }