Bootstraps Vellum as a single Go binary with one SQLite database. - core: env-driven config, slog logging, SQLite (WAL) with pooled pragmas, embedded migration runner keyed on PRAGMA user_version - migrations/0001_core.sql: identity, media, user state, infra. Media items are global; all per-user state (progress, favorites, grants) is a join. One polymorphic progress table covers every media kind. - httpapi: request-ID, structured request logging, panic recovery, JSON endpoints for /healthz and /api/v1/version - multi-arch Dockerfile (pure-Go SQLite, CGO_ENABLED=0) with an entrypoint that honours PUID/PGID like the rest of the self-hosted stack - tests for migration idempotency, FK enforcement, WAL, config, middleware Media does not exist yet. Nothing is exposed beyond health and version.
120 lines
3.3 KiB
Go
120 lines
3.3 KiB
Go
package core
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"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
|
|
|
|
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
|
|
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
|
|
}
|