Extract health check logic into GetHealth method on Config struct and integrate with Worker service for accurate scan status reporting. Changes: - Move health check handler from inline function to Config.GetHealth() - Add Worker field to Config struct for dependency injection - Wire Worker into main server dependencies - Report actual scan_in_progress status using Worker.HasActiveScans() - Report actual active_jobs count using Worker.GetActiveJobCount() This provides more accurate health monitoring by checking the real state of background jobs rather than returning static placeholder values.
276 lines
8.4 KiB
Go
276 lines
8.4 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/v4"
|
|
"github.com/labstack/echo/v4"
|
|
echomiddleware "github.com/labstack/echo/v4/middleware"
|
|
)
|
|
|
|
// 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
|
|
DBPool interface{} // pgxpool.Pool interface
|
|
AuthHandler *handlers.AuthHandler
|
|
LibraryHandler *handlers.LibraryHandler
|
|
DeviceHandler *handlers.DeviceHandler
|
|
MediaHandler *handlers.MediaHandler
|
|
MatchingHandler *handlers.MatchingHandler
|
|
KOReaderHandler *handlers.KOReaderHandler
|
|
WSHandler *handlers.WSHandler
|
|
ConflictHandler *handlers.ConflictHandler
|
|
AnalyticsHandler *handlers.AnalyticsHandler
|
|
QueueHandler *handlers.QueueHandler
|
|
CollectionHandler *handlers.CollectionHandler
|
|
Worker *services.Worker
|
|
DashboardHandler *handlers.DashboardHandler
|
|
DashboardService *services.DashboardService
|
|
OPDSHandler *handlers.OPDSHandler
|
|
SystemSettingsHandler *handlers.SystemSettingsHandler
|
|
ConnManager *sync.ConnectionManager
|
|
QueueProcessor *sync.SyncQueueProcessor
|
|
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
|
|
LoginTracker *ratelimit.LoginAttemptTracker
|
|
ScannerHandler *handlers.Handler
|
|
JobsHandler *handlers.JobsHandler
|
|
}
|
|
|
|
// 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) {
|
|
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 {
|
|
c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID in token"})
|
|
return
|
|
}
|
|
|
|
c.Set("user", database.Users{
|
|
ID: pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true},
|
|
Email: claims["user_email"].(string),
|
|
Username: claims["user_username"].(string),
|
|
Role: claims["user_role"].(string),
|
|
})
|
|
},
|
|
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.",
|
|
})
|
|
},
|
|
})
|
|
}
|
|
|
|
// ensureUserExistsMiddleware checks if the authenticated user still exists in the database
|
|
func ensureUserExistsMiddleware(cfg *Config) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
userIDStr, ok := c.Get("user_id").(string)
|
|
if !ok {
|
|
return next(c)
|
|
}
|
|
|
|
userUUID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
log.Printf("Invalid UUID in user existence check: %v", err)
|
|
return next(c)
|
|
}
|
|
|
|
// Check if user exists in database
|
|
_, err = cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID))
|
|
if err != nil {
|
|
log.Printf("User not found in database: %s", userIDStr)
|
|
|
|
// Clear invalid cookie
|
|
c.SetCookie(&http.Cookie{
|
|
Name: "token",
|
|
Value: "",
|
|
Expires: time.Now().Add(-24 * time.Hour),
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
|
|
// Redirect to login with session=invalid
|
|
return c.Redirect(http.StatusFound, "/login?session=invalid")
|
|
}
|
|
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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}
|
|
|
|
// Global middleware
|
|
e.Use(echomiddleware.Logger())
|
|
e.Use(echomiddleware.Recover())
|
|
e.Use(echomiddleware.CORS())
|
|
e.Use(ratelimit.RequestTracingMiddleware(cfg.Cfg))
|
|
|
|
// Rate limiter
|
|
rateLimiterConfig := ratelimit.RateLimiterConfig{
|
|
Enabled: cfg.Cfg.RateLimitEnabled,
|
|
RequestsPerMinute: cfg.Cfg.RequestsPerMinute,
|
|
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
|
|
registerAuthRoutes(cfg, rateLimitMiddleware)
|
|
registerLibraryRoutes(cfg)
|
|
registerDeviceRoutes(cfg)
|
|
registerSyncRoutes(cfg)
|
|
registerCollectionsRoutes(cfg)
|
|
registerDashboardRoutes(cfg)
|
|
registerMediaRoutes(cfg)
|
|
registerSearchRoutes(cfg)
|
|
registerMatchingRoutes(cfg)
|
|
registerConflictRoutes(cfg)
|
|
registerAnalyticsRoutes(cfg)
|
|
registerQueueRoutes(cfg)
|
|
registerJobRoutes(cfg)
|
|
registerOPDSRoutes(cfg)
|
|
registerWebSocketRoutes(cfg)
|
|
registerFrontendRoutes(cfg)
|
|
registerDocumentationRoutes(cfg)
|
|
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")
|
|
}
|
|
}()
|
|
|
|
// Register progress routes with actual handler
|
|
registerProgressRoutes(cfg, scannerHandler)
|
|
|
|
// Register scanner routes (admin only)
|
|
admin := protected.Group("", handlers.AdminMiddleware)
|
|
registerScannerRoutes(admin, scannerHandler)
|
|
|
|
return scannerHandler
|
|
}
|