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.
193 lines
6.4 KiB
Go
193 lines
6.4 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/mithral/vellum/internal/httpx"
|
|
)
|
|
|
|
// contextKey is unexported so no other package can collide with our context values.
|
|
type contextKey string
|
|
|
|
const (
|
|
userContextKey contextKey = "auth.user"
|
|
sessionContextKey contextKey = "auth.session"
|
|
)
|
|
|
|
// defaultTouchEvery throttles last_seen writes. last_seen exists for session
|
|
// listing and expiry hygiene, not security, so minute granularity is plenty and
|
|
// a write per request is not worth the contention it causes on SQLite's single
|
|
// write lock — especially for media playback, where one seek fires several
|
|
// Range requests that would otherwise each take the write lock.
|
|
const defaultTouchEvery = 5 * time.Minute
|
|
|
|
// Authenticator loads sessions from cookies and enforces access.
|
|
type Authenticator struct {
|
|
DB *sql.DB
|
|
CookieName string
|
|
CookieTTL time.Duration
|
|
Secure bool
|
|
Log *slog.Logger
|
|
|
|
// touchEvery is the minimum age of a session's last_seen before Middleware
|
|
// will write it again. A zero value means "touch on every request" (the
|
|
// pre-throttling behaviour), which is a safe fallback for hand-built values.
|
|
touchEvery time.Duration
|
|
}
|
|
|
|
// NewAuthenticator builds an Authenticator with sane cookie defaults.
|
|
func NewAuthenticator(db *sql.DB, cookieName string, ttl time.Duration, secure bool, log *slog.Logger) *Authenticator {
|
|
if cookieName == "" {
|
|
cookieName = "vellum_session"
|
|
}
|
|
return &Authenticator{
|
|
DB: db,
|
|
CookieName: cookieName,
|
|
CookieTTL: ttl,
|
|
Secure: secure,
|
|
Log: log,
|
|
touchEvery: defaultTouchEvery,
|
|
}
|
|
}
|
|
|
|
// Middleware attaches the current user to the request context when a valid
|
|
// session cookie is present. It never rejects a request: enforcement is the
|
|
// job of RequireAuth, so public routes can also see the caller.
|
|
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
if token, err := a.TokenFromRequest(r); err == nil {
|
|
if sess, err := SessionByToken(ctx, a.DB, token); err == nil {
|
|
if u, err := GetUserByID(ctx, a.DB, sess.UserID); err == nil {
|
|
ctx = context.WithValue(ctx, userContextKey, u)
|
|
ctx = context.WithValue(ctx, sessionContextKey, sess)
|
|
// Throttle the last_seen write. It is for session listing
|
|
// and expiry hygiene, not security, so minute granularity
|
|
// is plenty; a write on every request would take SQLite's
|
|
// single write lock each time, and a media seek alone can
|
|
// issue several Range requests. The decision is made from
|
|
// the LastSeen we already loaded via SessionByToken, so no
|
|
// extra query is added.
|
|
if time.Since(sess.LastSeen) >= a.touchEvery {
|
|
// Best-effort; must not fail the request.
|
|
if err := TouchSession(ctx, a.DB, sess.ID); err != nil {
|
|
a.Log.Warn("touch session", "err", err)
|
|
}
|
|
}
|
|
r = r.WithContext(ctx)
|
|
} else if a.Log != nil {
|
|
a.Log.Warn("session references missing user", "user_id", sess.UserID)
|
|
}
|
|
} else if err != ErrNoSession && a.Log != nil {
|
|
a.Log.Warn("load session", "err", err)
|
|
}
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// RequireAuth rejects unauthenticated requests with 401.
|
|
func (a *Authenticator) RequireAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := CurrentUser(r.Context()); !ok {
|
|
httpx.WriteError(w, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// RequireAdmin rejects authenticated non-admins with 403.
|
|
func (a *Authenticator) RequireAdmin(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
u, ok := CurrentUser(r.Context())
|
|
if !ok {
|
|
httpx.WriteError(w, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
if !u.IsAdmin {
|
|
httpx.WriteError(w, http.StatusForbidden, "administrator access required")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// Require calls fn with the current user, or 401s if there is none.
|
|
func (a *Authenticator) Require(fn func(http.ResponseWriter, *http.Request, User)) http.Handler {
|
|
return a.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
u, _ := CurrentUser(r.Context())
|
|
fn(w, r, u)
|
|
}))
|
|
}
|
|
|
|
// CurrentUser returns the authenticated user, if any.
|
|
func CurrentUser(ctx context.Context) (User, bool) {
|
|
u, ok := ctx.Value(userContextKey).(User)
|
|
return u, ok
|
|
}
|
|
|
|
// CurrentSession returns the session backing the authenticated user, if any.
|
|
func CurrentSession(ctx context.Context) (Session, bool) {
|
|
s, ok := ctx.Value(sessionContextKey).(Session)
|
|
return s, ok
|
|
}
|
|
|
|
// ── cookies ────────────────────────────────────────────────────────────
|
|
|
|
// SetSessionCookie issues the session cookie. HttpOnly and SameSite=Lax are
|
|
// always on; Secure follows configuration (it must be off for plain-HTTP dev).
|
|
func (a *Authenticator) SetSessionCookie(w http.ResponseWriter, token string, expires time.Time) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: a.CookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
Expires: expires,
|
|
MaxAge: int(time.Until(expires).Seconds()),
|
|
HttpOnly: true,
|
|
Secure: a.Secure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
// ClearSessionCookie removes the session cookie.
|
|
func (a *Authenticator) ClearSessionCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: a.CookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: a.Secure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
// TokenFromRequest reads the session token from the cookie, or the
|
|
// Authorization: Bearer header for non-browser clients (native apps, CLIs).
|
|
func (a *Authenticator) TokenFromRequest(r *http.Request) (string, error) {
|
|
if c, err := r.Cookie(a.CookieName); err == nil && c.Value != "" {
|
|
return c.Value, nil
|
|
}
|
|
if h := r.Header.Get("Authorization"); len(h) > 7 && h[:7] == "Bearer " {
|
|
return h[7:], nil
|
|
}
|
|
return "", ErrNoSession
|
|
}
|
|
|
|
// ClientIP extracts a best-effort client address for logging and rate limiting.
|
|
// It deliberately does NOT trust X-Forwarded-For: behind an untrusted network
|
|
// that header is attacker-controlled, and this server is LAN-facing.
|
|
func ClientIP(r *http.Request) string {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return r.RemoteAddr
|
|
}
|
|
return host
|
|
}
|