Vellum/cmd/vellum/main.go
mithral 5458b0a28a Phase 2: libraries and the file scanner
- library: CRUD with root-path resolution (absolute, symlink-free, must be
  readable). A library name is unique; the root is validated at creation.
- registry: media types declare their extensions; the core never imports a
  module. RegisterType is the extension point.
- scanner: walks a root, reconciles media_items/media_files in one
  transaction, skips unchanged files by size+mtime, and marks vanished files
  missing rather than deleting them (progress is the one thing here that
  cannot be regenerated). A single unreadable file never fails a scan.
- migration 0002: `missing` flags on items and files, plus indexes.
- endpoints: media-types, library list/get/create/delete, per-library items,
  and scan. Library reads go through the same grant chokepoint as everything
  else; an unauthorised library 404s rather than 403s so its existence is not
  leaked.
2026-10-09 16:33:56 -04:00

112 lines
2.8 KiB
Go

// Command vellum is the unified media server: one process, one database,
// every media type.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/httpapi"
"github.com/mithral/vellum/internal/library"
"github.com/mithral/vellum/migrations"
)
// version is overridden at build time with -ldflags "-X main.version=...".
var version = "dev"
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "vellum: fatal:", err)
os.Exit(1)
}
}
func run() error {
cfg, err := core.LoadConfig()
if err != nil {
return err
}
log := core.NewLogger(cfg.LogLevel, cfg.LogFormat)
log.Info("starting vellum", "version", version, "listen", cfg.Listen)
if err := cfg.Validate(); err != nil {
return err
}
db, err := core.OpenDB(cfg.DBPath)
if err != nil {
return err
}
defer db.Close()
migCtx, cancelMig := context.WithTimeout(context.Background(), 60*time.Second)
err = core.Migrate(migCtx, db, migrations.FS, log)
cancelMig()
if err != nil {
return err
}
api := httpapi.New(db, log, version)
authenticator := auth.NewAuthenticator(db, cfg.CookieName, cfg.SessionTTL, cfg.CookieSecure, log)
if !cfg.CookieSecure {
log.Warn("VELLUM_COOKIE_SECURE is false; session cookies will be sent over plain HTTP. " +
"Set it to true when serving over TLS.")
}
if err := auth.EnsureAdminFromEnv(context.Background(), db, log, cfg.AdminUser, cfg.AdminPassword); err != nil {
return fmt.Errorf("bootstrap admin: %w", err)
}
authHandlers := auth.NewHandlers(authenticator, log)
authHandlers.Register(api.Mux())
api.SetAuth(authenticator)
library.NewHandlers(db, log, authenticator).Register(api.Mux())
srv := &http.Server{
Addr: cfg.Listen,
Handler: api.Handler(),
// ReadHeaderTimeout protects against slowloris. WriteTimeout is
// deliberately zero: streaming large media files must not be cut off.
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
ErrorLog: nil,
}
errCh := make(chan error, 1)
go func() {
log.Info("listening", "addr", cfg.Listen, "data_dir", cfg.DataDir)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
select {
case err := <-errCh:
return fmt.Errorf("http server: %w", err)
case sig := <-stop:
log.Info("shutting down", "signal", sig.String())
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownGrace)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
}
log.Info("stopped")
return nil
}