package core import ( "fmt" "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 CookieName string CookieSecure 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) { 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", ""), CookieName: env("VELLUM_COOKIE_NAME", "vellum_session"), // Secure defaults to false so plain-HTTP development works. Production // behind TLS must set VELLUM_COOKIE_SECURE=true; boot logs a warning // when it is off. CookieSecure: envBool("VELLUM_COOKIE_SECURE", false), 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), } 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 } // 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 { for _, dir := range []string{c.DataDir, c.ConfigDir} { if err := os.MkdirAll(dir, 0o750); err != nil { return fmt.Errorf("create %s: %w", dir, 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 } 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 }