TouchSession throttle (internal/auth)
- Middleware wrote sessions.last_seen on EVERY authenticated request, taking
SQLite's single write lock each time. For media that is severe: one seek in
an audio file fires several Range requests, each taking the write lock and
competing with the scanner and progress saves.
- Now touched at most once per 5 minutes, decided from the LastSeen already
loaded by SessionByToken, so no extra query. A zero interval degrades to the
old per-request behaviour, so a hand-built Authenticator cannot silently stop
updating.
- Verified end to end with an AFTER UPDATE trigger: 20 requests now cause 0
session writes, down from 20.
Response writer forwarding (internal/httpapi)
- statusRecorder wrapped every ResponseWriter but implemented only Write and
WriteHeader, so it did not implement http.Flusher. That silently disabled
early flushing (progressive audio/video) and io.ReaderFrom, which is what
lets io.Copy use sendfile for multi-GB files. Now forwards Flush, Hijack and
ReadFrom, and WriteHeader is idempotent.
Security headers (internal/httpapi)
- Added nosniff, X-Frame-Options: DENY, Referrer-Policy and a CSP. Outermost in
the chain so a recovered panic's 500 still carries them.
- CORRECTION to an earlier claim: I said a malicious EPUB was stored XSS.
That was wrong. epub.js sets iframe sandbox to "allow-same-origin" and only
appends "allow-scripts" when settings.allowScriptedContent is true, which
defaults to false and Reader.svelte never sets. EPUB content cannot execute
script. The CSP is still worth having as defence in depth, but that specific
justification does not hold.
Final flush on teardown (AudiobookPlayer, Reader)
- The last playback/reading position was saved with a fire-and-forget fetch in
onDestroy, which the browser cancels on navigate-away — losing exactly the
thing worth persisting. Now persisted via fetch with keepalive: true, wired
to pagehide and visibilitychange(hidden), latched so the overlapping events
fire one request, and reset when the page becomes visible again so a bfcache
restore still flushes later. sendBeacon was not usable: it is POST-only and
progress is PUT-only.
- Also removed the queueMicrotask track-swap race in AudiobookPlayer: play() is
now driven by onLoadedMetadata once the new src has actually loaded.
Honest readers (Reader, ComicReader)
- registry.go advertises .mobi/.azw3/.fb2 under ebooks, but every ebooks item
was handed to epub.js, which cannot parse them. Now only .epub uses epub.js;
other containers show a message naming the format plus a download link. Items
stay catalogued and downloadable rather than silently vanishing.
- .cbr/.rar comics say the archive format is unsupported instead of surfacing
the server's raw 422 (the server reads zip only).
Verified in a real browser, not just compiled: all six reader surfaces render
(epub iframe, cbz images, audio element, photo, pdf canvas) with zero CSP
violations; .mobi shows the honest message with no epub.js iframe. gofmt/vet
clean, 14 packages pass, svelte-check 0/0.
Known issue, NOT introduced here and NOT fixed: the EPUB reader logs
"Error: No Element Provided" x3 and a TypeError in epub.js injectIdentifier
(doc.getElementsByTagName("head")[0] undefined). Proven pre-existing by A/B
against c50b3cf, and not caused by the test file (spec-correct XHTML) or by
caching (cold browser). Non-fatal: the book still renders. Needs its own
investigation.
283 lines
10 KiB
Go
283 lines
10 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 (
|
|
"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 <style> into the rendition iframe
|
|
// (epubjs rendition.injectStylesheet, contents.css).
|
|
// blob: EPUB stylesheets are rewritten to blob: URLs
|
|
// (epubjs book.js replacements="blobUrl", resources.js).
|
|
// img-src 'self' covers, comic pages, photos (ComicReader/PhotoViewer/Card).
|
|
// data: blob: EPUB images as data:/blob: URIs (epubjs resources.js).
|
|
// media-src 'self' blob: <audio> streams tracks via ServeContent (AudiobookPlayer.svelte).
|
|
// connect-src 'self' every API fetch() (web/src/lib/api.ts).
|
|
// https://cdn.emulatorjs.org EmulatorJS fetches its wasm/data files (GamePlayer.svelte).
|
|
// font-src 'self' data: blob: bundled fonts plus EPUB-embedded fonts as data:/blob:
|
|
// (epubjs resources.js rewrites fonts to blob: URLs).
|
|
// worker-src 'self' blob: pdf.js worker is a same-origin asset, with a blob: fallback
|
|
// (PdfReader.svelte `?url`); EmulatorJS spawns a blob: worker.
|
|
// frame-src 'self' blob: epub.js renders each section in an iframe (Reader.svelte;
|
|
// epubjs iframe.js srcdoc/blobUrl). Nothing uses data:.
|
|
// object-src 'none' no plugin content is ever embedded.
|
|
// base-uri/form-action/frame-ancestors no <base> hijack, no cross-site form posts, never framed.
|
|
const csp = "default-src 'self'; " +
|
|
"script-src 'self' 'wasm-unsafe-eval' https://cdn.emulatorjs.org; " +
|
|
"style-src 'self' 'unsafe-inline' blob:; " +
|
|
"img-src 'self' data: blob:; " +
|
|
"media-src 'self' blob:; " +
|
|
"connect-src 'self' https://cdn.emulatorjs.org; " +
|
|
"font-src 'self' data: blob:; " +
|
|
"worker-src 'self' blob:; " +
|
|
"frame-src 'self' blob:; " +
|
|
"object-src 'none'; " +
|
|
"base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
h := w.Header()
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("X-Frame-Options", "DENY")
|
|
h.Set("Referrer-Policy", "no-referrer")
|
|
h.Set("Content-Security-Policy", csp)
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
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 ────────────────────────────────────────────────────────────
|
|
|
|
// statusRecorder wraps a ResponseWriter to record the status code and byte
|
|
// count for logging.
|
|
//
|
|
// It MUST forward Flush, Hijack, and ReadFrom to the underlying writer.
|
|
// Wrapping a ResponseWriter without forwarding these silently disables
|
|
// streaming: http.ServeContent can no longer flush early (progressive
|
|
// audio/video stalls until the whole body is ready) and io.Copy can no longer
|
|
// reach the net/http ReaderFrom that drives kernel sendfile for multi-GB
|
|
// files. The type assertions below are the only reason media playback and
|
|
// downloads keep working once this middleware is installed.
|
|
type statusRecorder struct {
|
|
http.ResponseWriter
|
|
status int
|
|
bytes int
|
|
wroteHeader bool
|
|
}
|
|
|
|
// WriteHeader records the first status and ignores repeats. net/http logs a
|
|
// "superfluous WriteHeader" warning on a second call, and a handler that
|
|
// writes a body before setting a status would otherwise be recorded wrongly.
|
|
func (r *statusRecorder) WriteHeader(code int) {
|
|
if r.wroteHeader {
|
|
return
|
|
}
|
|
r.wroteHeader = true
|
|
r.status = code
|
|
r.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (r *statusRecorder) Write(b []byte) (int, error) {
|
|
if !r.wroteHeader {
|
|
r.WriteHeader(http.StatusOK)
|
|
}
|
|
n, err := r.ResponseWriter.Write(b)
|
|
r.bytes += n
|
|
return n, err
|
|
}
|
|
|
|
// Flush forwards to the underlying http.Flusher so handlers such as
|
|
// http.ServeContent can push bytes to the client before the body is complete.
|
|
func (r *statusRecorder) Flush() {
|
|
if f, ok := r.ResponseWriter.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
// Hijack forwards to the underlying http.Hijacker for protocols that take over
|
|
// the connection.
|
|
func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
if hj, ok := r.ResponseWriter.(http.Hijacker); ok {
|
|
return hj.Hijack()
|
|
}
|
|
return nil, nil, http.ErrNotSupported
|
|
}
|
|
|
|
// ReadFrom forwards to the underlying io.ReaderFrom so io.Copy keeps the
|
|
// sendfile fast path instead of looping through Write. The byte count is not
|
|
// updated on that path: wrapping src to count it would defeat the zero-copy
|
|
// transfer this method exists to preserve.
|
|
func (r *statusRecorder) ReadFrom(src io.Reader) (int64, error) {
|
|
if !r.wroteHeader {
|
|
r.WriteHeader(http.StatusOK)
|
|
}
|
|
if rf, ok := r.ResponseWriter.(io.ReaderFrom); ok {
|
|
return rf.ReadFrom(src)
|
|
}
|
|
n, err := io.Copy(r.ResponseWriter, src)
|
|
r.bytes += int(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,
|
|
})
|
|
}
|