Vellum/internal/core/config.go
mithral 9772e55c73 Phase 1: accounts, sessions, per-library grants
Multi-user from the first migration, as planned.

- auth: argon2id password hashing (PHC encoded, parameters travel with the
  hash), opaque session tokens stored only as SHA-256, login rate limiting,
  and account enumeration resistance (unknown user hashes anyway)
- sessions: cookie or Bearer auth, HttpOnly + SameSite=Lax, revocation on
  password change
- grants: library_grants is the single access-control chokepoint. Admins see
  every library; everyone else sees exactly their can_read grants
- bootstrap: VELLUM_ADMIN_USER/PASSWORD create the first admin only on an
  empty database. No default password is ever invented.
- httpapi: mounts modules on a shared mux, session middleware innermost
- httpx: shared JSON helpers, strict body decoding

Gate: two users see different libraries. Tests cover that, plus session
expiry, token-at-rest, grant upsert, and the full login/me/logout flow.
2026-10-09 16:31:24 -04:00

148 lines
4.1 KiB
Go

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
}