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 } // 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, }) 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") 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) admin.GET("/api/admin/hash-conflicts", cfg.HashConflictsHandler.ListHashConflicts) admin.POST("/api/admin/hash-conflicts/:id/resolve", cfg.HashConflictsHandler.ResolveHashConflict) return scannerHandler }