Theme flash (admin pages, docs, stats, progress — anywhere SSR'd):
the JWT middlewares built the context user WITHOUT the Theme column,
so every page rendering theme-{user.Theme} from c.Get("user") painted
an empty class (:root tokyo-night default) and only settled on the real
theme after initializeTheme -> loadUserTheme round-tripped the server.
Both middlewares now carry liveUser.Theme into the context user;
verified live: /admin /stats /progress /docs /docs-deep /dashboard
/collections all SSR theme-hackerman.
Defense in depth for the same bug: loadTheme() no longer re-applies
localStorage over a server-rendered body theme (applyTheme records even
the default, so any signed-out visit baked tokyo-night into localStorage
and every signed-in page flipped to it post-paint). Public auth/landing
bodies dropped their hardcoded theme classes so ThemeFlashGuard's
pre-paint html class actually governs them; docs theme <select> now
syncs from the body class and re-syncs after the profile fetch.
Sidebar state (the unpleasant part): the app sidebar's openPanel was
reinitialized from the pathname on every load; it now restores from
localStorage ('sidebar-open-panel') with the pathname default as
fallback and -persists every toggle. Docs sidebar sections/groups
carry data-key attributes and toggleCollapsible persists collapsed
state per key ('docs-nav-state'); restoreDocsNav() applies it at init —
server-rendered defaults still govern first visit.
make test green; rendering ownership OK (no new inline scripts); 146
vitests pass; live-verified SSR classes on 7 pages.
428 lines
15 KiB
Go
428 lines
15 KiB
Go
package router
|
|
|
|
import (
|
|
"bookhoard/internal/config"
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/handlers"
|
|
"bookhoard/internal/middleware"
|
|
ratelimit "bookhoard/internal/middleware"
|
|
"bookhoard/internal/services"
|
|
"bookhoard/internal/sync"
|
|
"bookhoard/templates"
|
|
"bytes"
|
|
"context"
|
|
"log"
|
|
"mime"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-playground/validator/v10"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
echojwt "github.com/labstack/echo-jwt/v5"
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
// CustomValidator wraps the go-playground validator
|
|
type CustomValidator struct {
|
|
validator *validator.Validate
|
|
}
|
|
|
|
func (cv *CustomValidator) Validate(i interface{}) error {
|
|
return cv.validator.Struct(i)
|
|
}
|
|
|
|
// Config holds all dependencies needed for route registration
|
|
type Config struct {
|
|
Echo *echo.Echo
|
|
Queries *database.Queries
|
|
Cfg *config.Config
|
|
Settings *database.SettingsRegistry
|
|
DBPool interface{} // pgxpool.Pool interface
|
|
AuthHandler *handlers.AuthHandler
|
|
LibraryHandler *handlers.LibraryHandler
|
|
DeviceHandler *handlers.DeviceHandler
|
|
MediaHandler *handlers.MediaHandler
|
|
MatchingHandler *handlers.MatchingHandler
|
|
ProcessingIssuesHandler *handlers.ProcessingIssuesHandler
|
|
HashConflictsHandler *handlers.HashConflictsHandler
|
|
KOReaderHandler *handlers.KOReaderHandler
|
|
WSHandler *handlers.WSHandler
|
|
ConflictHandler *handlers.ConflictHandler
|
|
AnalyticsHandler *handlers.AnalyticsHandler
|
|
StatsHandler *handlers.StatsHandler
|
|
StatisticsService *services.StatisticsService
|
|
QueueHandler *handlers.QueueHandler
|
|
CollectionHandler *handlers.CollectionHandler
|
|
Worker *services.Worker
|
|
FiltersHandler *handlers.FiltersHandler
|
|
DashboardHandler *handlers.DashboardHandler
|
|
DashboardService *services.DashboardService
|
|
SeriesHandler *handlers.SeriesHandler
|
|
OPDSHandler *handlers.OPDSHandler
|
|
SystemSettingsHandler *handlers.SystemSettingsHandler
|
|
ConnManager *sync.ConnectionManager
|
|
QueueProcessor *sync.SyncQueueProcessor
|
|
ProgressService *sync.ProgressService
|
|
AnnotationService *sync.AnnotationService
|
|
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
|
|
LoginTracker *ratelimit.LoginAttemptTracker
|
|
ScannerHandler *handlers.Handler
|
|
JobsHandler *handlers.JobsHandler
|
|
SidecarHandler *handlers.SidecarHandler
|
|
ReaderHandler *handlers.ReaderHandler
|
|
LibraryService *services.LibraryService
|
|
}
|
|
|
|
// getBaseURL returns the configured base URL from the database, falling back to
|
|
// the env var / config default. Uses a closure to adapt the database query to
|
|
// config.SystemConfigGetter.
|
|
func (cfg *Config) getBaseURL(ctx context.Context) string {
|
|
getter := func(ctx context.Context, key string) (string, error) {
|
|
row, err := cfg.Queries.GetSystemConfig(ctx, key)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return row.Value, nil
|
|
}
|
|
baseURL := config.GetBaseURL(ctx, getter)
|
|
if baseURL == "" {
|
|
baseURL = cfg.Cfg.BaseURL
|
|
}
|
|
return baseURL
|
|
}
|
|
|
|
// createOptionalAuthMiddleware parses the JWT when one is present but never
|
|
// rejects the request: pages rendered for both anonymous and signed-in
|
|
// visitors (the documentation) use it so handlers see a populated "user"
|
|
// when a valid token exists and nil otherwise.
|
|
//
|
|
// This is deliberately NOT echojwt: its ErrorHandler's return value ends the
|
|
// chain, so "return nil" for a missing token would abort the request with an
|
|
// empty 200 instead of continuing (that shipped once — every /docs page came
|
|
// back blank). A ghost-session token authenticates as nobody, same rule as
|
|
// the required middleware minus the redirect.
|
|
func createOptionalAuthMiddleware(cfg *Config) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c *echo.Context) error {
|
|
tokenString := ""
|
|
if cookie, err := c.Cookie("token"); err == nil && cookie.Value != "" {
|
|
tokenString = cookie.Value
|
|
} else if auth := c.Request().Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
|
tokenString = strings.TrimPrefix(auth, "Bearer ")
|
|
}
|
|
if tokenString == "" {
|
|
return next(c) // anonymous
|
|
}
|
|
|
|
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
|
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, jwt.ErrSignatureInvalid
|
|
}
|
|
return []byte(cfg.Cfg.JWTSecret), nil
|
|
})
|
|
if err != nil || !token.Valid {
|
|
return next(c) // invalid/expired → anonymous
|
|
}
|
|
claims, ok := token.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
return next(c)
|
|
}
|
|
|
|
userIDStr, _ := claims["user_id"].(string)
|
|
userUUID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
return next(c)
|
|
}
|
|
|
|
liveUser, exists := fetchLiveUser(cfg, userUUID)
|
|
if !exists {
|
|
return next(c) // ghost session → anonymous
|
|
}
|
|
|
|
c.Set("user", database.Users{
|
|
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
Email: claims["user_email"].(string),
|
|
Username: claims["user_username"].(string),
|
|
Role: claims["user_role"].(string),
|
|
Timezone: liveUser.Timezone,
|
|
Theme: liveUser.Theme, // SSR body class; see the required middleware
|
|
})
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// createJWTMiddleware creates a JWT middleware with proper user context setup
|
|
func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
|
return echojwt.WithConfig(echojwt.Config{
|
|
SigningKey: []byte(cfg.Cfg.JWTSecret),
|
|
ContextKey: "user",
|
|
TokenLookup: "cookie:token,header:Authorization:Bearer ",
|
|
SuccessHandler: func(c *echo.Context) error {
|
|
token := c.Get("user").(*jwt.Token)
|
|
claims := token.Claims.(jwt.MapClaims)
|
|
c.Set("user_id", claims["user_id"])
|
|
c.Set("user_role", claims["user_role"])
|
|
c.Set("user_email", claims["user_email"])
|
|
c.Set("user_username", claims["user_username"])
|
|
|
|
// Parse UUID from string claims
|
|
userIDStr, _ := claims["user_id"].(string)
|
|
userUUID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid user ID in token")
|
|
}
|
|
|
|
// Ghost-session guard: a signature-valid token for a user that
|
|
// no longer exists must not authenticate (deleted users kept
|
|
// full access until token expiry before this check). A
|
|
// primary-key lookup per request — instant revocation, no TTL
|
|
// semantics; the guard also covers the SSR groups that used to
|
|
// chain ensureUserExistsMiddleware. The fetched row also feeds
|
|
// the context stub: the user's timezone must travel with every
|
|
// request so server-side time rendering converts per-user.
|
|
liveUser, exists := fetchLiveUser(cfg, userUUID)
|
|
if !exists {
|
|
log.Printf("Ghost session rejected: user %s no longer exists", userIDStr)
|
|
c.SetCookie(&http.Cookie{
|
|
Name: "token",
|
|
Value: "",
|
|
Expires: time.Now().Add(-24 * time.Hour),
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
if wantsHTML(c.Request().Header) {
|
|
return c.Redirect(http.StatusFound, "/login?session=invalid")
|
|
}
|
|
// Non-nil error is REQUIRED: returning c.JSON's nil would
|
|
// let the chain continue into handlers whose c.Get("user")
|
|
// assertion panics on the never-set user.
|
|
return echo.NewHTTPError(http.StatusUnauthorized,
|
|
"user_not_found: this account no longer exists; please log in again")
|
|
}
|
|
|
|
c.Set("user", database.Users{
|
|
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
Email: claims["user_email"].(string),
|
|
Username: claims["user_username"].(string),
|
|
Role: claims["user_role"].(string),
|
|
Timezone: liveUser.Timezone,
|
|
// SSR pages render `theme-{user.Theme}` into <body>; without
|
|
// this the class renders empty and every page painted the
|
|
// :root default (tokyo-night) before JS caught up — the
|
|
// admin/docs theme flash.
|
|
Theme: liveUser.Theme,
|
|
})
|
|
return nil
|
|
},
|
|
ErrorHandler: func(c *echo.Context, err error) error {
|
|
// Check if this is a page request (browser navigation)
|
|
if wantsHTML(c.Request().Header) {
|
|
// Page request → Redirect to login with message
|
|
loginURL := "/login?session=expired"
|
|
return c.Redirect(http.StatusFound, loginURL)
|
|
}
|
|
|
|
// API request → Return JSON error
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{
|
|
"error": "session_expired",
|
|
"message": "Your session has expired. Please log in again.",
|
|
})
|
|
},
|
|
})
|
|
}
|
|
|
|
// userExists checks the users table for the token's claimed user. Runs on
|
|
// every authenticated request: a primary-key lookup, cheap at this scale,
|
|
// and it makes deletion revocable the moment it happens.
|
|
func userExists(cfg *Config, id uuid.UUID) bool {
|
|
_, err := cfg.Queries.GetUser(context.Background(), uuidToPGType(id))
|
|
return err == nil
|
|
}
|
|
|
|
// fetchLiveUser loads the full user row for the JWT stub — the ghost-session
|
|
// guard and the timezone travel together on this one per-request lookup.
|
|
func fetchLiveUser(cfg *Config, id uuid.UUID) (database.GetUserRow, bool) {
|
|
u, err := cfg.Queries.GetUser(context.Background(), uuidToPGType(id))
|
|
return u, err == nil
|
|
}
|
|
|
|
// wantsHTML determines if the request expects HTML response
|
|
func wantsHTML(header http.Header) bool {
|
|
// Check Accept header
|
|
accept := header.Get("Accept")
|
|
if accept != "" && (accept == "text/html" || strings.Contains(accept, "text/html")) {
|
|
return true
|
|
}
|
|
|
|
// Check HTMX request
|
|
if header.Get("HX-Request") == "true" {
|
|
return true
|
|
}
|
|
|
|
// Check for AJAX requests (should get JSON)
|
|
if header.Get("X-Requested-With") == "XMLHttpRequest" {
|
|
return false
|
|
}
|
|
|
|
// Default to JSON for API routes
|
|
return false
|
|
}
|
|
|
|
// RegisterRoutes registers all application routes
|
|
func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|
// Go's builtin mime table doesn't know the audiobook containers; the
|
|
// /uploads file serving and any ServeContent path needs real audio
|
|
// content types or media elements refuse the stream.
|
|
mime.AddExtensionType(".m4b", "audio/mp4")
|
|
mime.AddExtensionType(".m4a", "audio/mp4")
|
|
mime.AddExtensionType(".mp3", "audio/mpeg")
|
|
mime.AddExtensionType(".flac", "audio/flac")
|
|
mime.AddExtensionType(".opus", "audio/ogg")
|
|
mime.AddExtensionType(".ogg", "audio/ogg")
|
|
mime.AddExtensionType(".oga", "audio/ogg")
|
|
mime.AddExtensionType(".aac", "audio/aac")
|
|
mime.AddExtensionType(".wav", "audio/wav")
|
|
e := cfg.Echo
|
|
|
|
// Set up validator
|
|
v := validator.New()
|
|
if err := ratelimit.RegisterPasswordValidation(v); err != nil {
|
|
log.Fatal("Failed to register password validator:", err)
|
|
}
|
|
e.Validator = &CustomValidator{validator: v}
|
|
|
|
// Setup redirect middleware - must run before all routes
|
|
e.Pre(setupRedirectMiddleware(cfg))
|
|
|
|
// Rate limiter. The per-minute value comes from the settings registry (DB);
|
|
// the enabled flag stays env-driven since disabling rate limiting is a
|
|
// deployment-time decision, not a runtime tunable.
|
|
rateLimiterConfig := ratelimit.RateLimiterConfig{
|
|
Enabled: cfg.Cfg.RateLimitEnabled,
|
|
RequestsPerMinute: cfg.Settings.AuthRateLimit(),
|
|
CleanupInterval: 5 * time.Minute,
|
|
}
|
|
rateLimiter := ratelimit.NewRateLimiter(rateLimiterConfig)
|
|
rateLimitMiddleware := ratelimit.RateLimiterMiddleware(rateLimiter)
|
|
|
|
// Register core application routes (collections, devices, media, etc.) - ONCE
|
|
jwtMiddleware := createJWTMiddleware(cfg)
|
|
protected := e.Group("/api", jwtMiddleware)
|
|
|
|
// Create scanner handler for scanner routes and progress routes
|
|
scannerHandler := handlers.SetupRoutes(protected, cfg.Queries, cfg.ConnManager, cfg.QueueProcessor, cfg.Cfg)
|
|
cfg.ScannerHandler = scannerHandler
|
|
|
|
// Register route groups
|
|
registerSetupRoutes(cfg)
|
|
registerAuthRoutes(cfg, rateLimitMiddleware)
|
|
registerLibraryRoutes(cfg)
|
|
registerDeviceRoutes(cfg)
|
|
registerSystemRoutes(cfg)
|
|
registerSyncRoutes(cfg)
|
|
registerCollectionsRoutes(cfg)
|
|
registerSeriesRoutes(cfg)
|
|
registerDashboardRoutes(cfg)
|
|
registerMediaRoutes(cfg)
|
|
registerSearchRoutes(cfg)
|
|
registerMatchingRoutes(cfg)
|
|
registerConflictRoutes(cfg)
|
|
registerAnalyticsRoutes(cfg)
|
|
registerQueueRoutes(cfg)
|
|
registerJobRoutes(cfg)
|
|
registerFiltersRoutes(cfg)
|
|
registerOPDSRoutes(cfg)
|
|
registerReaderRoutes(cfg)
|
|
registerWebSocketRoutes(cfg)
|
|
registerFrontendRoutes(cfg)
|
|
registerDocumentationRoutes(cfg)
|
|
|
|
// Static bundles are served unhashed (reader.js, index.js), so the
|
|
// browser must revalidate on every load — with no Cache-Control it
|
|
// fell back to heuristic caching and served stale bundles for hours
|
|
// after a deploy (user-visible: fixed web bugs "not fixed"). no-cache
|
|
// still allows cheap If-Modified-Since 304 revalidation.
|
|
e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c *echo.Context) error {
|
|
if strings.HasPrefix(c.Request().URL.Path, "/static/") {
|
|
c.Response().Header().Set(echo.HeaderCacheControl, "no-cache")
|
|
}
|
|
return next(c)
|
|
}
|
|
})
|
|
|
|
e.Static("/static", "web/static")
|
|
|
|
// Catch-all 404 handler - must be last
|
|
e.GET("/*", func(c *echo.Context) error {
|
|
var buf bytes.Buffer
|
|
err := templates.ErrorPage("Page not found", "404").Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusNotFound, buf.String())
|
|
})
|
|
|
|
// Start background tasks (queue processor and connection cleanup)
|
|
scannerHandler.StartBackgroundTasks()
|
|
|
|
// Start watch mode for all libraries (after 2 second delay for DB)
|
|
go func() {
|
|
time.Sleep(2 * time.Second)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
setting, err := cfg.Queries.GetSystemSetting(ctx, "auto_scan_enabled")
|
|
enabled := true // default
|
|
if err == nil && setting != "" {
|
|
enabled, _ = strconv.ParseBool(setting)
|
|
}
|
|
|
|
if !enabled {
|
|
log.Println("Auto-scan disabled in settings, skipping watch mode startup")
|
|
return
|
|
}
|
|
|
|
log.Println("Starting watch mode for all libraries...")
|
|
if err := scannerHandler.StartWatchModeForAllLibraries(context.Background()); err != nil {
|
|
log.Printf("Failed to start watch mode: %v", err)
|
|
} else {
|
|
log.Println("Watch mode started successfully")
|
|
}
|
|
}()
|
|
|
|
// One-time hash backfill: compute and store SHA-256 for media items
|
|
// imported before hashing existed, then flag any content-duplicate groups
|
|
// for admin review on the Hash Conflicts page. Runs independently of
|
|
// auto-scan (it is a one-shot self-heal, not a recurring scan) and is a
|
|
// no-op once every item is hashed. Delayed so it does not compete with
|
|
// startup scans for disk I/O.
|
|
go func() {
|
|
time.Sleep(30 * time.Second)
|
|
services.NewHashBackfillService(cfg.Queries).Run(context.Background())
|
|
}()
|
|
|
|
// Register progress routes with actual handler
|
|
registerProgressRoutes(cfg, scannerHandler)
|
|
|
|
// Register scanner routes (admin only, plus the sitewide summary)
|
|
admin := protected.Group("", handlers.AdminMiddleware)
|
|
registerScannerRoutes(protected, admin, scannerHandler)
|
|
|
|
// Hash conflict routes (admin only). NOTE: `admin` already sits under the
|
|
// /api group (protected), so these paths must NOT start with /api —
|
|
// they used to, producing /api/api/admin/hash-conflicts while the admin
|
|
// page's htmx buttons posted to /api/admin/hash-conflicts/:id/resolve,
|
|
// which 404'd silently (htmx swaps nothing on error responses).
|
|
admin.GET("/admin/hash-conflicts", cfg.HashConflictsHandler.ListHashConflicts)
|
|
admin.POST("/admin/hash-conflicts/:id/resolve", cfg.HashConflictsHandler.ResolveHashConflict)
|
|
|
|
return scannerHandler
|
|
}
|