commit eb1ad6d920106e0d130742b2bcf56a043598db38 Author: mithral Date: Fri Oct 9 16:26:08 2026 -0400 Phase 0: skeleton, config, migrations, health check, container 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. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1e5a40e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitignore +data/ +config/ +dist/ +*.db +*.db-wal +*.db-shm +/vellum +Dockerfile +compose.yml +README.md +*.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99c66a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Build artifacts +/vellum +/dist/ +*.exe + +# Runtime state — never commit a database +/data/ +/config/ +*.db +*.db-wal +*.db-shm + +# Editor / OS +.DS_Store +*.swp +.idea/ +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..845803b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# syntax=docker/dockerfile:1 + +# ── Build ────────────────────────────────────────────────────────────── +# Runs on the builder's native platform; cross-compiles to the target arch. +# CGO_ENABLED=0 works because the SQLite driver (modernc.org/sqlite) is pure +# Go, so there is no cgo and no libc to match against. +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build + +ARG TARGETOS=linux +ARG TARGETARCH=amd64 +ARG VERSION=dev + +WORKDIR /src + +# Cache the module layer separately from the source. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \ + -o /out/vellum ./cmd/vellum + +# ── Runtime ──────────────────────────────────────────────────────────── +FROM alpine:3.22 + +RUN apk add --no-cache ca-certificates tzdata su-exec + +COPY --from=build /out/vellum /usr/local/bin/vellum +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +# /data persistent state (SQLite database) — back this up +# /config regenerable cache (thumbnails, extracted pages) +# /media the library, mounted read-only; Vellum never writes here +VOLUME ["/data", "/config"] + +ENV VELLUM_DATA_DIR=/data \ + VELLUM_CONFIG_DIR=/config \ + VELLUM_LISTEN=:7788 + +# PUID/PGID are consumed by entrypoint.sh, which drops root privileges after +# taking ownership of the data and cache directories. +ENV PUID=1001 \ + PGID=1001 + +EXPOSE 7788 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1:7788/healthz || exit 1 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..91d04a4 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +# Vellum — developer commands. +# +# Requires Go >= 1.26. On Fedora: sudo dnf install golang + +BINARY := vellum +VERSION ?= 0.1.0-dev +LDFLAGS := -s -w -X main.version=$(VERSION) + +.PHONY: build run test fmt vet tidy check docker clean + +build: + CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/vellum + +run: + go run ./cmd/vellum + +test: + go test ./... + +fmt: + gofmt -w . + +vet: + go vet ./... + +tidy: + go mod tidy + +# What CI should run. +check: fmt vet test + +docker: + podman build --build-arg VERSION=$(VERSION) -t localhost/vellum:$(VERSION) . + +clean: + rm -f $(BINARY) + rm -rf data config diff --git a/README.md b/README.md new file mode 100644 index 0000000..3899586 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# Vellum + +One application, one process, one database for every media type — ebooks, +comics, audiobooks, music, photos, games, and video — behind a single account +system with real per-user progress and per-library permissions. + +A modular monolith. One Go binary, one SQLite file, one web frontend. + +The full plan lives in `~/.opencode/plan/vellum.md`. + +## Status + +| Phase | Scope | State | +| --- | --- | --- | +| **0** | Repo, config, migrations, health, Docker | **done** | +| 1 | Users, sessions, library grants | not started | +| 2 | Scanner, items, assets, FTS5 search | not started | +| 3 | EPUB reader + progress | not started | +| 4 | Comic (CBZ/PDF) viewers | not started | +| 5 | Metadata providers | not started | +| 6 | Audiobooks, music, photos | not started | +| 7 | Games | not started | +| — | Video (stays on Jellyfin for now) | deferred | + +Phase 0 delivers a binary that boots, migrates its schema, serves a health +check, and shuts down cleanly. No media features yet. + +## Quick start + +```bash +sudo dnf install golang # once; Go is not in the base image here +make build +make run +curl localhost:7788/healthz # {"database":"ok","status":"ok",...} +``` + +Run with media roots configured: + +```bash +VELLUM_MEDIA_DIRS=/mnt/storage/media/ebooks:/mnt/storage/media/comics make run +``` + +## Configuration + +All configuration is environment variables. Every one has a default, so the +binary runs with no configuration at all. + +| Variable | Default | Meaning | +| --- | --- | --- | +| `VELLUM_LISTEN` | `:7788` | Bind address | +| `VELLUM_DATA_DIR` | `./data` | SQLite database lives here | +| `VELLUM_CONFIG_DIR` | `./config` | Regenerable cache (thumbnails, pages) | +| `VELLUM_MEDIA_DIRS` | *(none)* | Read-only library roots, colon-separated | +| `VELLUM_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` | +| `VELLUM_LOG_FORMAT` | `text` | `text` or `json` | +| `VELLUM_SESSION_TTL` | `720h` | Login session lifetime | +| `VELLUM_SHUTDOWN_GRACE` | `15s` | Graceful shutdown timeout | + +## Layout + +``` +cmd/vellum/ main — wiring and lifecycle only +internal/ + core/ config, logging, database, migration runner + httpapi/ router, middleware, handlers +migrations/ embedded SQL, applied in order at boot +``` + +**Module rule:** the core never imports a feature module; modules import the +core and mount their own routes, tables, and scanner rules. Adding a media type +is a new package plus a migration, never a core edit. + +**Schema rule:** media items are global; everything user-specific is a join. +Nothing may hold per-user state on a media row. + +## Database + +SQLite in WAL mode, opened through `modernc.org/sqlite` — a pure-Go driver, so +the binary builds with `CGO_ENABLED=0` and cross-compiles without a toolchain +per target. + +Migrations are embedded and applied in filename order; applied files are +recorded only by number in `PRAGMA user_version`, so **never edit an applied +migration** — add a new one. + +Back up by copying the database (or its directory); `VACUUM INTO 'backup.db'` +takes a consistent snapshot while running. + +## Deploy + +```bash +make docker # podman build +podman compose up -d # see compose.yml +``` + +The container mounts `/data` (database), `/config` (cache), and `/media` +(read-only). On SELinux hosts every bind mount needs `:z` — `compose.yml` +already has it. + +The application never writes to `/media`. + +## Development + +```bash +make check # gofmt + vet + test +make test +``` diff --git a/cmd/vellum/main.go b/cmd/vellum/main.go new file mode 100644 index 0000000..1e19a71 --- /dev/null +++ b/cmd/vellum/main.go @@ -0,0 +1,93 @@ +// 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/core" + "github.com/mithral/vellum/internal/httpapi" + "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) + 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 +} diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..401d5f4 --- /dev/null +++ b/compose.yml @@ -0,0 +1,45 @@ +# Vellum — one container, alongside the existing stack. +# +# Mounts match the conventions used elsewhere in this stack: +# :z SELinux relabel (mandatory on this host, getenforce = Enforcing) +# :ro the media library is read-only; Vellum never writes to it +# +# Paths here are the TARGET HOST's (/opt/mediaserver, /mnt/storage). They do +# not exist on the dev workstation — build locally with the Dockerfile instead. + +services: + vellum: + build: + context: . + args: + VERSION: 0.1.0-dev + image: localhost/vellum:0.1.0-dev + container_name: vellum + restart: unless-stopped + security_opt: + - no-new-privileges:true + deploy: + resources: + limits: + cpus: '2.0' + memory: 1G + environment: + - PUID=1001 + - PGID=1001 + - TZ=America/New_York + - VELLUM_LISTEN=:7788 + - VELLUM_LOG_LEVEL=info + # Read-only library roots, colon-separated. Add comics/music/etc. as + # those modules land. + - VELLUM_MEDIA_DIRS=/media/ebooks:/media/comics + volumes: + - /opt/mediaserver/vellum/data:/data:z + - /opt/mediaserver/vellum/config:/config:z + - /mnt/storage/media:/media:ro,z + ports: + - 127.0.0.1:7788:7788 + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..d27fc93 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Vellum container entrypoint. +# +# Honours PUID/PGID the same way the rest of this stack does: when started as +# root, take ownership of the data and cache directories and drop privileges to +# the requested user. This is what lets a bind-mounted host directory owned by +# an arbitrary UID work without the operator matching IDs by hand. +# +# If already running as non-root (rootless podman with --userns=keep-id), skip +# all of it and just exec the binary. +set -eu + +PUID="${PUID:-1001}" +PGID="${PGID:-1001}" + +if [ "$(id -u)" != "0" ]; then + exec /usr/local/bin/vellum "$@" +fi + +# Non-recursive: the application creates its own files, and recursively +# chowning a large thumbnail/page cache on every boot would be needlessly slow. +for dir in /data /config; do + mkdir -p "$dir" + chown "$PUID:$PGID" "$dir" 2>/dev/null || true +done + +exec su-exec "$PUID:$PGID" /usr/local/bin/vellum "$@" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..42a965c --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/mithral/vellum + +go 1.26.8 + +require modernc.org/sqlite v1.60.1 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.48.0 // indirect + modernc.org/libc v1.77.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8b50aa6 --- /dev/null +++ b/go.sum @@ -0,0 +1,50 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= +modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE= +modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs= +modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw= +modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/core/config.go b/internal/core/config.go new file mode 100644 index 0000000..f345374 --- /dev/null +++ b/internal/core/config.go @@ -0,0 +1,120 @@ +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 +} diff --git a/internal/core/config_test.go b/internal/core/config_test.go new file mode 100644 index 0000000..2773261 --- /dev/null +++ b/internal/core/config_test.go @@ -0,0 +1,77 @@ +package core + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestLoadConfigDefaults(t *testing.T) { + // No environment set: every field should have a usable default. + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.Listen != ":7788" { + t.Errorf("Listen = %q, want :7788", cfg.Listen) + } + if !filepath.IsAbs(cfg.DataDir) { + t.Errorf("DataDir = %q, want absolute", cfg.DataDir) + } + if cfg.DBPath != filepath.Join(cfg.DataDir, "vellum.db") { + t.Errorf("DBPath = %q, want /vellum.db", cfg.DBPath) + } + if cfg.LogLevel != "info" { + t.Errorf("LogLevel = %q, want info", cfg.LogLevel) + } +} + +func TestLoadConfigFromEnv(t *testing.T) { + t.Setenv("VELLUM_LISTEN", "127.0.0.1:9999") + t.Setenv("VELLUM_DATA_DIR", "/tmp/vellum-data") + t.Setenv("VELLUM_MEDIA_DIRS", "/media/a:/media/b") + t.Setenv("VELLUM_SESSION_TTL", "48h") + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.Listen != "127.0.0.1:9999" { + t.Errorf("Listen = %q", cfg.Listen) + } + if cfg.DataDir != "/tmp/vellum-data" { + t.Errorf("DataDir = %q", cfg.DataDir) + } + if len(cfg.MediaDirs) != 2 || cfg.MediaDirs[0] != "/media/a" { + t.Errorf("MediaDirs = %v", cfg.MediaDirs) + } + if cfg.SessionTTL.Hours() != 48 { + t.Errorf("SessionTTL = %v, want 48h", cfg.SessionTTL) + } +} + +func TestValidateRejectsMissingMediaDir(t *testing.T) { + cfg := Config{ + DataDir: t.TempDir(), + ConfigDir: t.TempDir(), + MediaDirs: []string{"/definitely/not/a/real/path"}, + } + err := cfg.Validate() + if err == nil { + t.Fatal("expected error for missing media dir") + } + if !strings.Contains(err.Error(), "/definitely/not/a/real/path") { + t.Errorf("error %q should name the offending path", err) + } +} + +func TestValidateCreatesDirs(t *testing.T) { + base := t.TempDir() + cfg := Config{ + DataDir: filepath.Join(base, "data"), + ConfigDir: filepath.Join(base, "config"), + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } +} diff --git a/internal/core/db.go b/internal/core/db.go new file mode 100644 index 0000000..fbfb96f --- /dev/null +++ b/internal/core/db.go @@ -0,0 +1,60 @@ +package core + +import ( + "database/sql" + "fmt" + "net/url" + "strings" + "time" + + // Pure-Go SQLite driver. No cgo means CGO_ENABLED=0 static binaries and + // trivial cross-compilation for arm64. + _ "modernc.org/sqlite" +) + +// pragmas applied to every pooled connection, via the DSN. +// +// These are passed in the DSN rather than run as statements after connecting +// because database/sql pools connections: a one-off PRAGMA would configure +// only whichever single connection happened to serve it. +var sqlitePragmas = []string{ + "journal_mode(WAL)", // concurrent readers during a scan + "busy_timeout(5000)", // wait for the writer instead of failing + "foreign_keys(1)", // enforce the schema's referential integrity + "synchronous(NORMAL)", // safe under WAL, far faster than FULL + "cache_size(-32000)", // ~32 MB page cache +} + +// OpenDB opens the SQLite database with the pragmas Vellum depends on. +func OpenDB(path string) (*sql.DB, error) { + db, err := sql.Open("sqlite", buildDSN(path, sqlitePragmas)) + if err != nil { + return nil, fmt.Errorf("open sqlite %s: %w", path, err) + } + + // SQLite is a single-writer database. A large pool just produces + // SQLITE_BUSY contention; a small pool with a busy_timeout is faster. + db.SetMaxOpenConns(8) + db.SetMaxIdleConns(8) + db.SetConnMaxLifetime(0) + db.SetConnMaxIdleTime(5 * time.Minute) + + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("ping sqlite %s: %w", path, err) + } + return db, nil +} + +// buildDSN assembles a modernc.org/sqlite DSN with repeated _pragma params. +func buildDSN(path string, pragmas []string) string { + var b strings.Builder + b.WriteString("file:") + b.WriteString(path) + b.WriteString("?_time_format=sqlite") + for _, p := range pragmas { + b.WriteString("&_pragma=") + b.WriteString(url.QueryEscape(p)) + } + return b.String() +} diff --git a/internal/core/logging.go b/internal/core/logging.go new file mode 100644 index 0000000..9383d26 --- /dev/null +++ b/internal/core/logging.go @@ -0,0 +1,34 @@ +package core + +import ( + "log/slog" + "os" + "strings" +) + +// NewLogger builds a structured logger from the configured level and format. +// Unknown values fall back rather than failing: a bad log level must never +// stop the server from booting. +func NewLogger(level, format string) *slog.Logger { + var lvl slog.Level + switch strings.ToLower(level) { + case "debug": + lvl = slog.LevelDebug + case "warn", "warning": + lvl = slog.LevelWarn + case "error": + lvl = slog.LevelError + default: + lvl = slog.LevelInfo + } + + opts := &slog.HandlerOptions{Level: lvl} + + var h slog.Handler + if strings.EqualFold(format, "json") { + h = slog.NewJSONHandler(os.Stdout, opts) + } else { + h = slog.NewTextHandler(os.Stdout, opts) + } + return slog.New(h) +} diff --git a/internal/core/migrate.go b/internal/core/migrate.go new file mode 100644 index 0000000..eb4ab5c --- /dev/null +++ b/internal/core/migrate.go @@ -0,0 +1,138 @@ +package core + +import ( + "context" + "database/sql" + "fmt" + "io/fs" + "log/slog" + "sort" + "strconv" + "strings" +) + +// Migrate applies every embedded migration whose number is greater than the +// database's current PRAGMA user_version, in ascending order, each inside its +// own transaction. +// +// A migration is recorded by its numeric prefix. This is why applied files are +// never edited: the runner has no checksum, only a version number, so changing +// an applied file would silently diverge a running database from the schema. +func Migrate(ctx context.Context, db *sql.DB, fsys fs.FS, log *slog.Logger) error { + names, err := fs.Glob(fsys, "*.sql") + if err != nil { + return fmt.Errorf("list migrations: %w", err) + } + sort.Strings(names) + + current, err := userVersion(ctx, db) + if err != nil { + return err + } + log.Debug("schema version", "current", current, "available", len(names)) + + for _, name := range names { + n, err := migrationNumber(name) + if err != nil { + return err + } + if n <= current { + continue + } + if err := applyMigration(ctx, db, fsys, name, n); err != nil { + return fmt.Errorf("migration %s: %w", name, err) + } + log.Info("applied migration", "file", name, "version", n) + } + return nil +} + +func applyMigration(ctx context.Context, db *sql.DB, fsys fs.FS, name string, n int) error { + body, err := fs.ReadFile(fsys, name) + if err != nil { + return err + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() //nolint:errcheck // no-op once Commit succeeds + + for _, stmt := range splitStatements(string(body)) { + if _, err := tx.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("exec: %w\nstatement:\n%s", err, stmt) + } + } + + // PRAGMA does not accept a bound parameter. n is parsed from the filename + // as an integer above, so it is not attacker-controlled. + if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", n)); err != nil { + return fmt.Errorf("set user_version: %w", err) + } + return tx.Commit() +} + +func userVersion(ctx context.Context, db *sql.DB) (int, error) { + var v int + if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&v); err != nil { + return 0, fmt.Errorf("read user_version: %w", err) + } + return v, nil +} + +// migrationNumber parses the numeric prefix of "0001_core.sql" -> 1. +func migrationNumber(name string) (int, error) { + base := name + if i := strings.IndexByte(base, '_'); i >= 0 { + base = base[:i] + } else if i := strings.IndexByte(base, '.'); i >= 0 { + base = base[:i] + } + n, err := strconv.Atoi(base) + if err != nil { + return 0, fmt.Errorf("migration %q: filename must start with a number, e.g. 0001_core.sql", name) + } + return n, nil +} + +// splitStatements splits a SQL file on semicolons that terminate a statement. +// +// This is not a general SQL parser. It is correct for the schema files this +// project writes: no stored procedures, no BEGIN...END blocks, no semicolons +// inside string literals. Comments are stripped first so that a ';' inside a +// comment cannot split a statement. +func splitStatements(sqlText string) []string { + var out []string + for _, stmt := range strings.Split(stripSQLComments(sqlText), ";") { + if s := strings.TrimSpace(stmt); s != "" { + out = append(out, s) + } + } + return out +} + +// stripSQLComments removes -- line comments and /* */ block comments. +func stripSQLComments(s string) string { + var b strings.Builder + b.Grow(len(s)) + + for i := 0; i < len(s); { + switch { + case strings.HasPrefix(s[i:], "--"): + for i < len(s) && s[i] != '\n' { + i++ + } + case strings.HasPrefix(s[i:], "/*"): + i += 2 + for i < len(s) && !strings.HasPrefix(s[i:], "*/") { + i++ + } + i += 2 + default: + b.WriteByte(s[i]) + i++ + } + } + return b.String() +} diff --git a/internal/core/migrate_test.go b/internal/core/migrate_test.go new file mode 100644 index 0000000..e05911d --- /dev/null +++ b/internal/core/migrate_test.go @@ -0,0 +1,124 @@ +package core + +import ( + "context" + "database/sql" + "io" + "log/slog" + "path/filepath" + "strings" + "testing" + + "github.com/mithral/vellum/migrations" +) + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func openTempDB(t *testing.T) *sql.DB { + t.Helper() + db, err := OpenDB(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func TestMigrateAppliesAndIsIdempotent(t *testing.T) { + ctx := context.Background() + db := openTempDB(t) + + if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil { + t.Fatalf("first Migrate: %v", err) + } + + // Running again must be a no-op, not an error. + if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil { + t.Fatalf("second Migrate (should be no-op): %v", err) + } + + var version int + if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { + t.Fatalf("read user_version: %v", err) + } + if version != 1 { + t.Errorf("user_version = %d, want 1", version) + } + + var tables int + if err := db.QueryRowContext(ctx, + "SELECT count(*) FROM sqlite_master WHERE type='table'").Scan(&tables); err != nil { + t.Fatalf("count tables: %v", err) + } + if tables < 16 { + t.Errorf("table count = %d, want >= 16", tables) + } + + // WAL must be active: progress writes during a scan depend on it. + var mode string + if err := db.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&mode); err != nil { + t.Fatalf("read journal_mode: %v", err) + } + if !strings.EqualFold(mode, "wal") { + t.Errorf("journal_mode = %q, want wal", mode) + } +} + +func TestForeignKeyEnforcementIsOn(t *testing.T) { + ctx := context.Background() + db := openTempDB(t) + if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil { + t.Fatalf("Migrate: %v", err) + } + + // library_grants references users and libraries; neither 999 exists. + _, err := db.ExecContext(ctx, + "INSERT INTO library_grants(user_id, library_id) VALUES (999, 999)") + if err == nil { + t.Fatal("expected foreign key violation, got nil") + } +} + +func TestMigrationNumber(t *testing.T) { + cases := map[string]int{ + "0001_core.sql": 1, + "0042_audiobooks.sql": 42, + "7_shorthand.sql": 7, + "0010_no_ext": 10, + } + for name, want := range cases { + got, err := migrationNumber(name) + if err != nil { + t.Errorf("migrationNumber(%q): %v", name, err) + continue + } + if got != want { + t.Errorf("migrationNumber(%q) = %d, want %d", name, got, want) + } + } + + if _, err := migrationNumber("core.sql"); err == nil { + t.Error("expected error for filename without a numeric prefix") + } +} + +func TestSplitStatementsIgnoresComments(t *testing.T) { + in := ` +-- a comment with a semicolon; which must not split +CREATE TABLE a (id INTEGER); -- trailing comment; +/* block; comment; here */ +CREATE TABLE b (id INTEGER); +` + got := splitStatements(in) + if len(got) != 2 { + t.Fatalf("got %d statements, want 2: %#v", len(got), got) + } + if !strings.Contains(got[0], "CREATE TABLE a") { + t.Errorf("statement 0 = %q", got[0]) + } + if !strings.Contains(got[1], "CREATE TABLE b") { + t.Errorf("statement 1 = %q", got[1]) + } +} diff --git a/internal/httpapi/health.go b/internal/httpapi/health.go new file mode 100644 index 0000000..32676e8 --- /dev/null +++ b/internal/httpapi/health.go @@ -0,0 +1,45 @@ +package httpapi + +import ( + "context" + "net/http" + "time" +) + +// handleHealth reports process liveness and database reachability. +// +// Returns 503 when the database cannot be pinged, so a container orchestrator +// restarts a wedged process instead of routing traffic to it. +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + + dbStatus := "ok" + code := http.StatusOK + if err := s.db.PingContext(ctx); err != nil { + dbStatus = "unreachable" + code = http.StatusServiceUnavailable + s.log.Error("health check: database unreachable", "err", err) + } + + status := "ok" + if code != http.StatusOK { + status = "degraded" + } + + writeJSON(w, code, map[string]any{ + "status": status, + "version": s.version, + "uptime_seconds": int(time.Since(s.started).Seconds()), + "database": dbStatus, + }) +} + +// handleVersion returns build information. +func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "name": "vellum", + "version": s.version, + "api": "v1", + }) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go new file mode 100644 index 0000000..8e64a3d --- /dev/null +++ b/internal/httpapi/server.go @@ -0,0 +1,152 @@ +// Package httpapi is the HTTP layer: routing, middleware, and handlers. +// +// In Phase 0 it serves only health and version. Feature modules (auth, library, +// ebooks, comics, ...) mount their own routes onto the same mux via +// Server.Handle, so this package never imports a feature module. +package httpapi + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "log/slog" + "net/http" + "time" +) + +// Server holds the dependencies every handler needs. +type Server struct { + db *sql.DB + log *slog.Logger + version string + started time.Time + mux *http.ServeMux +} + +// New builds a Server and registers the core routes. +func New(db *sql.DB, log *slog.Logger, version string) *Server { + s := &Server{ + db: db, + log: log, + version: version, + started: time.Now(), + mux: http.NewServeMux(), + } + s.routes() + return s +} + +// Handle lets a module mount routes on the shared mux. +func (s *Server) Handle(pattern string, h http.Handler) { + s.mux.Handle(pattern, h) +} + +func (s *Server) routes() { + s.mux.HandleFunc("GET /healthz", s.handleHealth) + s.mux.HandleFunc("GET /api/v1/version", s.handleVersion) + s.mux.HandleFunc("/", s.handleNotFound) +} + +// Handler returns the fully wrapped root handler. +// +// Order matters: the request ID must be attached before the logging +// middleware runs, so the log line can include it. +func (s *Server) Handler() http.Handler { + return s.withRecovery(s.withRequestID(s.withRequestLogging(s.mux))) +} + +// ── Middleware ───────────────────────────────────────────────────────── + +type ctxKey string + +const ctxRequestID ctxKey = "request_id" + +func withValue(ctx context.Context, k ctxKey, v string) context.Context { + return context.WithValue(ctx, k, v) +} + +func valueString(ctx context.Context, k ctxKey) string { + if v, ok := ctx.Value(k).(string); ok { + return v + } + return "" +} + +func (s *Server) withRequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get("X-Request-ID") + if id == "" { + var b [8]byte + _, _ = rand.Read(b[:]) + id = hex.EncodeToString(b[:]) + } + w.Header().Set("X-Request-ID", id) + next.ServeHTTP(w, r.WithContext(withValue(r.Context(), ctxRequestID, id))) + }) +} + +func (s *Server) withRequestLogging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + s.log.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", rec.status, + "bytes", rec.bytes, + "duration_ms", time.Since(start).Milliseconds(), + "request_id", valueString(r.Context(), ctxRequestID), + ) + }) +} + +func (s *Server) withRecovery(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + s.log.Error("panic", + "path", r.URL.Path, + "recover", rec, + "request_id", valueString(r.Context(), ctxRequestID), + ) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + }() + next.ServeHTTP(w, r) + }) +} + +// ── Helpers ──────────────────────────────────────────────────────────── + +type statusRecorder struct { + http.ResponseWriter + status int + bytes int +} + +func (r *statusRecorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} + +func (r *statusRecorder) Write(b []byte) (int, error) { + n, err := r.ResponseWriter.Write(b) + r.bytes += n + return n, err +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "not found", + "path": r.URL.Path, + }) +} diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go new file mode 100644 index 0000000..993c566 --- /dev/null +++ b/internal/httpapi/server_test.go @@ -0,0 +1,107 @@ +package httpapi + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/mithral/vellum/internal/core" + "github.com/mithral/vellum/migrations" +) + +func newTestServer(t *testing.T) *Server { + t.Helper() + db, err := core.OpenDB(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil { + t.Fatalf("Migrate: %v", err) + } + return New(db, log, "test") +} + +func TestHealth(t *testing.T) { + srv := newTestServer(t) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + srv.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["status"] != "ok" { + t.Errorf("status = %v, want ok", body["status"]) + } + if body["version"] != "test" { + t.Errorf("version = %v, want test", body["version"]) + } +} + +func TestNotFoundIsJSON(t *testing.T) { + srv := newTestServer(t) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/nope", nil) + srv.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct == "" { + t.Error("missing Content-Type on 404") + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("404 body is not JSON: %v", err) + } +} + +func TestRequestIDHeaderAndLog(t *testing.T) { + srv := newTestServer(t) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + srv.Handler().ServeHTTP(rec, req) + + if rec.Header().Get("X-Request-ID") == "" { + t.Error("X-Request-ID header not set") + } + + // A caller-supplied ID must be preserved. + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/healthz", nil) + req2.Header.Set("X-Request-ID", "abc123") + srv.Handler().ServeHTTP(rec2, req2) + if got := rec2.Header().Get("X-Request-ID"); got != "abc123" { + t.Errorf("X-Request-ID = %q, want abc123", got) + } +} + +func TestRecoveryMiddleware(t *testing.T) { + srv := newTestServer(t) + srv.Handle("GET /panic", http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + panic("boom") + })) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/panic", nil) + srv.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } +} diff --git a/migrations/0001_core.sql b/migrations/0001_core.sql new file mode 100644 index 0000000..299fcd3 --- /dev/null +++ b/migrations/0001_core.sql @@ -0,0 +1,176 @@ +-- 0001_core.sql — Vellum core schema. +-- +-- Structural rule: media items are global; everything user-specific is a join. +-- Nothing in this file may hold per-user state on a media row. +-- +-- Timestamps are ISO-8601 UTC TEXT. Booleans are INTEGER 0/1. +-- `kind` columns are intentionally unrestricted TEXT: media modules declare +-- their own kinds and must not require a core migration to do so. + +-- ── Identity ─────────────────────────────────────────────────────────── + +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + display_name TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + device_label TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + expires_at TEXT NOT NULL +); +CREATE INDEX idx_sessions_user ON sessions(user_id); +CREATE INDEX idx_sessions_expires ON sessions(expires_at); + +-- A library is a root path plus a media kind. Global. +CREATE TABLE libraries ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, + root_path TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +-- Per-user access to a library. The ONLY place read permission is expressed. +CREATE TABLE library_grants ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, + can_read INTEGER NOT NULL DEFAULT 1, + can_download INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, library_id) +); + +-- ── Media (global) ───────────────────────────────────────────────────── + +CREATE TABLE media_items ( + id INTEGER PRIMARY KEY, + library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + path TEXT NOT NULL, + parent_id INTEGER REFERENCES media_items(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT '', + sort_title TEXT NOT NULL DEFAULT '', + year INTEGER, + added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + UNIQUE (library_id, path) +); +CREATE INDEX idx_items_parent ON media_items(parent_id); +CREATE INDEX idx_items_kind ON media_items(kind); +CREATE INDEX idx_items_library ON media_items(library_id); + +CREATE TABLE media_files ( + id INTEGER PRIMARY KEY, + item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + path TEXT NOT NULL UNIQUE, + size INTEGER NOT NULL DEFAULT 0, + mtime TEXT NOT NULL DEFAULT '', + inode INTEGER NOT NULL DEFAULT 0, + container TEXT NOT NULL DEFAULT '', + duration_ms INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX idx_files_item ON media_files(item_id); + +CREATE TABLE people ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE COLLATE NOCASE +); + +CREATE TABLE item_people ( + item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE, + role TEXT NOT NULL, + ord INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (item_id, person_id, role) +); + +-- External metadata identifiers. UNIQUE so a provider record maps to one item. +CREATE TABLE provider_ids ( + item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + external_id TEXT NOT NULL, + PRIMARY KEY (item_id, provider), + UNIQUE (provider, external_id) +); + +-- Covers, thumbnails, extracted comic pages. Global, shared by all users. +CREATE TABLE assets ( + id INTEGER PRIMARY KEY, + item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + role TEXT NOT NULL, + path TEXT NOT NULL, + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + bytes INTEGER NOT NULL DEFAULT 0, + UNIQUE (item_id, role, path) +); + +-- ── User state (all joins) ───────────────────────────────────────────── + +-- One polymorphic progress table for every media kind. +-- audio/video/music -> position and duration in milliseconds +-- ebook -> position is a spine index or CFI, duration 0 +-- comic/pdf -> position is the page number, duration 0 +-- PK includes file_id so multi-file items (audiobook tracks) resume per track. +CREATE TABLE progress ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + file_id INTEGER NOT NULL REFERENCES media_files(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + duration INTEGER NOT NULL DEFAULT 0, + percent REAL NOT NULL DEFAULT 0, + finished INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + PRIMARY KEY (user_id, item_id, file_id) +); +CREATE INDEX idx_progress_user_updated ON progress(user_id, updated_at DESC); + +CREATE TABLE collections ( + id INTEGER PRIMARY KEY, + owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + visibility TEXT NOT NULL DEFAULT 'private' +); + +CREATE TABLE collection_items ( + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + PRIMARY KEY (collection_id, item_id) +); + +CREATE TABLE favorites ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + PRIMARY KEY (user_id, item_id) +); + +-- ── Infrastructure ───────────────────────────────────────────────────── + +CREATE TABLE jobs ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL, + payload TEXT NOT NULL DEFAULT '{}', + state TEXT NOT NULL DEFAULT 'queued', -- queued|running|done|failed + attempts INTEGER NOT NULL DEFAULT 0, + run_after TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX idx_jobs_claim ON jobs(state, run_after); + +CREATE TABLE settings ( + k TEXT PRIMARY KEY, + v TEXT NOT NULL DEFAULT '' +); diff --git a/migrations/embed.go b/migrations/embed.go new file mode 100644 index 0000000..d2e74ac --- /dev/null +++ b/migrations/embed.go @@ -0,0 +1,14 @@ +// Package migrations embeds the numbered SQL schema files. +// +// Files are applied in filename order by internal/core.Migrate. A new schema +// change is always a NEW file (NNNN_description.sql); existing files are never +// edited, because applied files are recorded only by their number in +// PRAGMA user_version and rewriting history silently corrupts existing DBs. +package migrations + +import "embed" + +// FS holds every .sql migration, embedded into the binary. +// +//go:embed *.sql +var FS embed.FS