package core import ( "fmt" "net" "os" "path/filepath" "strconv" "strings" "time" ) // Config is the complete runtime configuration, loaded from the environment. // Every field has a working default so the binary runs with no configuration // at all — important for "podman run" first-run experience. type Config struct { // Listen is the address the HTTP server binds, e.g. ":7788". Listen string // DataDir holds the SQLite database and persistent state. DataDir string // ConfigDir holds derived, regenerable artifacts (thumbnails, page cache). ConfigDir string // MediaDirs are read-only library roots. Vellum never writes here. MediaDirs []string DBPath string LogLevel string LogFormat string // AdminUser / AdminPassword create the first administrator, but only when // the users table is empty. Ignored on any established database. AdminUser string AdminPassword string // ComicVineAPIKey enables comic metadata. ComicVine has no keyless tier, so // the provider is simply absent when this is empty. ComicVineAPIKey string // IGDBClientID / IGDBClientSecret enable game metadata via Twitch's // client-credentials flow. Both must be set for the provider to appear. IGDBClientID string IGDBClientSecret string CookieName string CookieSecure bool // PublicURL is the external origin Vellum is reached at, e.g. // "https://media.example.com". Empty means "not published", which is the // case for plain-HTTP LAN use. It is the signal that the deployment sits // behind a TLS-terminating proxy, so it also drives the CookieSecure default. PublicURL string // TrustedProxies are the networks allowed to speak for a client in // X-Forwarded-For. Empty means trust nobody: only RemoteAddr is used. TrustedProxies []*net.IPNet // Discovery advertises the server on the LAN over mDNS so clients find it // without a typed URL. Discovery bool SessionTTL time.Duration ReadTimeout time.Duration WriteTimeout time.Duration IdleTimeout time.Duration ShutdownGrace time.Duration } // LoadConfig reads configuration from the environment and fills in defaults. // It does not create directories; call Validate for that. func LoadConfig() (Config, error) { publicURL := env("VELLUM_PUBLIC_URL", "") c := Config{ Listen: env("VELLUM_LISTEN", ":7788"), DataDir: env("VELLUM_DATA_DIR", "./data"), ConfigDir: env("VELLUM_CONFIG_DIR", "./config"), MediaDirs: envList("VELLUM_MEDIA_DIRS", nil), LogLevel: env("VELLUM_LOG_LEVEL", "info"), LogFormat: env("VELLUM_LOG_FORMAT", "text"), // text|json AdminUser: env("VELLUM_ADMIN_USER", ""), AdminPassword: env("VELLUM_ADMIN_PASSWORD", ""), ComicVineAPIKey: env("VELLUM_COMICVINE_API_KEY", ""), IGDBClientID: env("VELLUM_IGDB_CLIENT_ID", ""), IGDBClientSecret: env("VELLUM_IGDB_CLIENT_SECRET", ""), CookieName: env("VELLUM_COOKIE_NAME", "vellum_session"), // Secure defaults to true for an https:// PublicURL — a published origin // is TLS-terminated, so plain-HTTP dev still gets false with it unset. // VELLUM_COOKIE_SECURE overrides both ways; boot logs a warning when off. CookieSecure: envBool("VELLUM_COOKIE_SECURE", strings.HasPrefix(publicURL, "https://")), PublicURL: publicURL, SessionTTL: envDuration("VELLUM_SESSION_TTL", 30*24*time.Hour), ReadTimeout: envDuration("VELLUM_READ_TIMEOUT", 15*time.Second), WriteTimeout: envDuration("VELLUM_WRITE_TIMEOUT", 0), // 0 = no limit; streaming needs this IdleTimeout: envDuration("VELLUM_IDLE_TIMEOUT", 120*time.Second), ShutdownGrace: envDuration("VELLUM_SHUTDOWN_GRACE", 15*time.Second), Discovery: !envOff("VELLUM_DISCOVERY"), } proxies, err := envCIDRs("VELLUM_TRUSTED_PROXIES") if err != nil { return Config{}, err } c.TrustedProxies = proxies abs, err := filepath.Abs(c.DataDir) if err != nil { return Config{}, fmt.Errorf("resolve data dir: %w", err) } c.DataDir = abs c.DBPath = filepath.Join(abs, "vellum.db") if abs, err := filepath.Abs(c.ConfigDir); err == nil { c.ConfigDir = abs } return c, nil } // EnsureDirs creates the data and config directories if missing. // // Separate from Validate because the CLI needs the directories but has no // interest in the media roots: `vellum user add` must work on a fresh install, // and must not fail because a library happens to be unmounted. func (c Config) EnsureDirs() error { for _, dir := range []string{c.DataDir, c.ConfigDir} { if err := os.MkdirAll(dir, 0o750); err != nil { return fmt.Errorf("create %s: %w", dir, err) } } return nil } // Validate creates the data and config directories if missing and checks that // the configured media roots exist. A missing media root is an error, not a // warning: silently scanning nothing is worse than failing loudly at boot. func (c Config) Validate() error { if err := c.EnsureDirs(); err != nil { return err } for _, dir := range c.MediaDirs { info, err := os.Stat(dir) if err != nil { return fmt.Errorf("media dir %s: %w", dir, err) } if !info.IsDir() { return fmt.Errorf("media dir %s: not a directory", dir) } } return nil } func env(key, def string) string { if v, ok := os.LookupEnv(key); ok && v != "" { return v } return def } // envList parses a colon-separated list, the same separator as $PATH. func envList(key string, def []string) []string { v, ok := os.LookupEnv(key) if !ok || strings.TrimSpace(v) == "" { return def } var out []string for _, part := range strings.Split(v, ":") { if part = strings.TrimSpace(part); part != "" { out = append(out, part) } } return out } // envCIDRs parses a comma-separated CIDR list, e.g. // "172.16.0.0/12,127.0.0.1/32". A malformed entry is an error rather than a // silent skip: dropping a proxy network quietly widens the trust boundary, // which is exactly the failure this setting exists to prevent. func envCIDRs(key string) ([]*net.IPNet, error) { var out []*net.IPNet for _, part := range strings.Split(env(key, ""), ",") { if part = strings.TrimSpace(part); part == "" { continue } _, n, err := net.ParseCIDR(part) if err != nil { return nil, fmt.Errorf("%s: %w", key, err) } out = append(out, n) } return out, nil } func envDuration(key string, def time.Duration) time.Duration { v, ok := os.LookupEnv(key) if !ok || v == "" { return def } d, err := time.ParseDuration(v) if err != nil { return def } return d } func envBool(key string, def bool) bool { v, ok := os.LookupEnv(key) if !ok || v == "" { return def } b, err := strconv.ParseBool(v) if err != nil { return def } return b } // envOff reads a switch that is ON unless it is explicitly turned off. Unlike // envBool it accepts the on/off spellings operators actually type, because // strconv.ParseBool rejects "off" and would silently leave the feature on. func envOff(key string) bool { switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { case "off", "no", "false", "0": return true } return false }