The frontend the app needs to be usable at all. - web/: Svelte 5 + Vite + TypeScript SPA. Login, app shell with per-type nav, library list/create/scan/delete, item grid with covers, admin user management, and an epub.js reader that syncs progress. - Reader resumes by spine index — an integer — which is why the polymorphic progress table needs no text column. - Session state uses classic stores in a plain .ts module rather than runes in a .svelte.ts module, avoiding special module resolution. - Embedding: web/embed.go embeds web/dist. A committed .gitkeep keeps the directive valid before the frontend is built, so `go build ./...` never fails. Unknown non-API paths serve index.html for client routes; /api/* and /healthz always return JSON and never fall back to HTML. - Dockerfile builds the UI in a node stage and overlays dist into the Go stage. - pnpm 12 needs allowBuilds in pnpm-workspace.yaml; esbuild approved, the two unused transitive scripts explicitly denied so install exits 0. svelte-check: 0 errors, 0 warnings. Verified served: SPA shell at /, route fallback, hashed asset, and JSON 404 on /api/*.
167 lines
4.6 KiB
Go
167 lines
4.6 KiB
Go
// 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"
|
|
|
|
"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: 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.withRecovery(s.withRequestID(s.withRequestLogging(h)))
|
|
}
|
|
|
|
// ── 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,
|
|
})
|
|
}
|