// 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 ( "bufio" "context" "crypto/rand" "database/sql" "encoding/hex" "encoding/json" "io" "log/slog" "net" "net/http" "time" "github.com/mithral/vellum/internal/auth" ) // Server holds the dependencies every handler needs. type Server struct { db *sql.DB log *slog.Logger version string started time.Time mux *http.ServeMux auth *auth.Authenticator } // 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() s.mountWeb() return s } // Mux exposes the root mux so feature modules can mount their own routes. func (s *Server) Mux() *http.ServeMux { return s.mux } // SetAuth installs the session middleware. Until it is set, no request carries // a current user. func (s *Server) SetAuth(a *auth.Authenticator) { s.auth = a } // 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) } // Handler returns the fully wrapped root handler. // // Order matters. Security headers wrap the entire stack (outermost) so they are // written before anything can commit a response, including the recovery // middleware's 500 after a panic. Once a handler calls WriteHeader the header // map is frozen, so a security middleware placed inside recovery would be too // late for any panicking route. The request ID must be attached before the // logging middleware runs, so the log line can include it. Session loading sits // innermost so it runs after logging has started timing. func (s *Server) Handler() http.Handler { var h http.Handler = s.mux if s.auth != nil { h = s.auth.Middleware(h) } return s.withSecurityHeaders(s.withRecovery(s.withRequestID(s.withRequestLogging(h)))) } // ── Middleware ───────────────────────────────────────────────────────── // withSecurityHeaders sets a baseline of hardening headers on every response. // // Vellum renders user-supplied EPUB content on its own origin, so a malicious // EPUB is stored XSS unless the browser refuses to run foreign/inline script // and to frame the app. The policy is checked against the actual frontend; // each directive names the file that requires it. Keep it in sync when a // reader adds a new origin. // // It must sit outermost (see Handler): the headers have to be in place before // any handler — including the panic-recovery 500 — writes its status line. func (s *Server) withSecurityHeaders(next http.Handler) http.Handler { // default-src 'self' every bundled asset is same-origin (web/dist/index.html). // script-src 'self' the bundled ES module (web/dist/index.html). // 'wasm-unsafe-eval' EmulatorJS cores are WebAssembly (GamePlayer.svelte). // https://cdn.emulatorjs.org loader.js and core scripts (GamePlayer.svelte DATA_PATH). // style-src 'self' the bundled stylesheet (web/dist/index.html). // 'unsafe-inline' epub.js injects a