The static handler sent no Cache-Control, so browsers fell back to heuristic caching (10% of age since Last-Modified) and kept serving a stale reader.js for hours after a deploy — fixed web bugs reported as "not fixed". no-cache forces revalidation every load; the filesystem Last-Modified/If-Modified-Since pair keeps unchanged loads cheap. Found while closing out the PDF adjust v2 session: the user's Chrome was running a pre-endpoint bundle, which read as "the web has not been fixed".
332 lines
11 KiB
Go
332 lines
11 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"
|
|
"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
|
|
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
|
|
}
|
|
|
|
// 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.
|
|
if !userExists(cfg, userUUID) {
|
|
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),
|
|
})
|
|
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
|
|
}
|
|
|
|
// 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 {
|
|
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)
|
|
admin := protected.Group("", handlers.AdminMiddleware)
|
|
registerScannerRoutes(admin, scannerHandler)
|
|
|
|
// Hash conflict routes (admin only)
|
|
admin.GET("/api/admin/hash-conflicts", cfg.HashConflictsHandler.ListHashConflicts)
|
|
admin.POST("/api/admin/hash-conflicts/:id/resolve", cfg.HashConflictsHandler.ResolveHashConflict)
|
|
|
|
return scannerHandler
|
|
}
|