From 9bc8cd7bf3259bd2f990ef7c14bea0c13917b986 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 6 Feb 2026 11:09:26 -0500 Subject: [PATCH] feat: add router package structure for route organization Create internal/router/ package to organize route registration: - router.go: Main router setup and configuration - auth.go: Authentication routes (login, register, profile, etc.) - docs.go: Documentation routes - frontend.go: Frontend SSR routes (/, /login, /admin, etc.) - helpers.go: Helper functions for template rendering This is the first step in refactoring 858-line main.go into a more maintainable structure following Go best practices. Routes themselves have NOT changed - only organization. --- internal/router/auth.go | 56 ++++++++++ internal/router/docs.go | 13 +++ internal/router/frontend.go | 213 ++++++++++++++++++++++++++++++++++++ internal/router/helpers.go | 97 ++++++++++++++++ internal/router/router.go | 125 +++++++++++++++++++++ 5 files changed, 504 insertions(+) create mode 100644 internal/router/auth.go create mode 100644 internal/router/docs.go create mode 100644 internal/router/frontend.go create mode 100644 internal/router/helpers.go create mode 100644 internal/router/router.go diff --git a/internal/router/auth.go b/internal/router/auth.go new file mode 100644 index 0000000..fdc7649 --- /dev/null +++ b/internal/router/auth.go @@ -0,0 +1,56 @@ +package router + +import ( + "bookhoard/internal/handlers" + + "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo-jwt/v4" + "github.com/labstack/echo/v4" +) + +func registerAuthRoutes(cfg *Config, rateLimitMiddleware echo.MiddlewareFunc) { + e := cfg.Echo + + // Auth routes (no auth required, but rate limited) + e.POST("/api/auth/register", rateLimitMiddleware(cfg.AuthHandler.Register)) + e.POST("/api/auth/login", rateLimitMiddleware(cfg.AuthHandler.Login)) + + // JWT middleware for protected routes + jwtMiddleware := echojwt.WithConfig(echojwt.Config{ + SigningKey: []byte(cfg.Cfg.JWTSecret), + ContextKey: "user", + 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"]) + }, + }) + + // Create protected route group + protected := e.Group("/api", jwtMiddleware) + + // Protected auth routes + protected.GET("/auth/profile", cfg.AuthHandler.GetProfile) + protected.PUT("/auth/profile", cfg.AuthHandler.UpdateProfile) + + // Refresh token endpoint (no authentication required - uses refresh token from body) + e.POST("/api/auth/refresh", cfg.AuthHandler.RefreshAccessToken) + + // Logout endpoint (optional authentication - can revoke tokens if provided) + e.POST("/api/auth/logout", cfg.AuthHandler.Logout) + + // Auth update routes + authGroup := e.Group("/api/auth", jwtMiddleware) + authGroup.PUT("/email", cfg.AuthHandler.UpdateEmail) + authGroup.PUT("/username", cfg.AuthHandler.UpdateUsername) + authGroup.PUT("/password", cfg.AuthHandler.UpdatePassword) + authGroup.PUT("/theme", cfg.AuthHandler.UpdateTheme) + + // Admin-only routes for user management + admin := protected.Group("/auth", handlers.AdminMiddleware) + admin.GET("/users", cfg.AuthHandler.ListUsers) + admin.PUT("/users/:id/max-devices", cfg.AuthHandler.UpdateUserMaxDevices) +} diff --git a/internal/router/docs.go b/internal/router/docs.go new file mode 100644 index 0000000..60af358 --- /dev/null +++ b/internal/router/docs.go @@ -0,0 +1,13 @@ +package router + +import ( + "bookhoard/internal/docs" +) + +func registerDocumentationRoutes(cfg *Config) { + docsHandler := docs.NewHTTPHandler("docs") + cfg.Echo.GET("/docs", docsHandler.DocsHome) + cfg.Echo.GET("/docs/*", docsHandler.ShowDocumentation) + cfg.Echo.GET("/docs/api/search", docsHandler.Search) + cfg.Echo.GET("/docs/search-index.json", docsHandler.ServeSearchIndex) +} diff --git a/internal/router/frontend.go b/internal/router/frontend.go new file mode 100644 index 0000000..d8b7bd4 --- /dev/null +++ b/internal/router/frontend.go @@ -0,0 +1,213 @@ +package router + +import ( + "bytes" + "context" + "net/http" + "time" + + "bookhoard/internal/handlers" + "bookhoard/templates" + + "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo-jwt/v4" + "github.com/labstack/echo/v4" +) + +func registerFrontendRoutes(cfg *Config) { + e := cfg.Echo + + // ============================================================================ + // FRONTEND ROUTES - DO NOT DELETE + // These routes serve Server-Side Rendered (SSR) HTML pages for the web UI. + // They are NOT API endpoints and should NOT be removed during refactors. + // All authenticated frontend routes use the jwtMiddleware to validate tokens. + // ============================================================================ + + // JWT middleware for protected routes + jwtMiddleware := echojwt.WithConfig(echojwt.Config{ + SigningKey: []byte(cfg.Cfg.JWTSecret), + ContextKey: "user", + 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"]) + }, + }) + + // Protected route group + protected := e.Group("/api", jwtMiddleware) + + // Public routes for login and registration pages + e.GET("/login", func(c echo.Context) error { + var buf bytes.Buffer + err := templates.Login().Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + + e.GET("/register", func(c echo.Context) error { + var buf bytes.Buffer + err := templates.Register().Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + + // Root route - landing page with smart login detection + e.GET("/", func(c echo.Context) error { + var buf bytes.Buffer + var err error + + tokenString := c.Request().Header.Get("Authorization") + if tokenString != "" && len(tokenString) > 7 && tokenString[:7] == "Bearer " { + tokenString = tokenString[7:] + } else { + cookie, err := c.Cookie("token") + if err == nil { + tokenString = cookie.Value + } + } + + loggedIn := false + if tokenString != "" { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(cfg.Cfg.JWTSecret), nil + }) + loggedIn = err == nil && token.Valid + } + + err = templates.Index(loggedIn).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + + // Public redirect routes + e.GET("/bookshelf", func(c echo.Context) error { + return c.Redirect(http.StatusTemporaryRedirect, "/api/bookshelf") + }) + + e.GET("/dashboard", func(c echo.Context) error { + return c.Redirect(http.StatusTemporaryRedirect, "/api/bookshelf") + }) + + // Admin routes + e.GET("/admin", handlers.AdminMiddleware(func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user") + } + var buf bytes.Buffer + err = templates.Admin(user).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + })) + + e.GET("/admin/", handlers.AdminMiddleware(func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user") + } + var buf bytes.Buffer + err = templates.Admin(user).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + })) + + e.GET("/admin/profile", handlers.AdminMiddleware(func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user") + } + var buf bytes.Buffer + err = templates.AdminProfile(user).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + })) + + e.GET("/admin/library", handlers.AdminMiddleware(func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user") + } + var buf bytes.Buffer + err = templates.AdminLibrary(user).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + })) + + // Devices page + protected.GET("/devices-page", func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user") + } + deviceData, err := cfg.DeviceHandler.GetDevicesData(c) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading devices") + } + pendingData, err := cfg.DeviceHandler.GetPendingRegistrationsData(c) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading pending") + } + devicesList := convertDevices(deviceData) + pendingList := convertPending(pendingData) + var buf bytes.Buffer + err = templates.Devices(user, devicesList, pendingList).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + + // Conflicts page + protected.GET("/conflicts-page", func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user") + } + conflictsData, total, unresolved, err := cfg.ConflictHandler.GetConflictsData(c) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading conflicts") + } + var buf bytes.Buffer + err = templates.Conflicts(user, conflictsData, total, unresolved).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + + // Health check + e.GET("/health", func(c echo.Context) error { + ctx, cancel := context.WithTimeout(c.Request().Context(), 2*time.Second) + defer cancel() + + if err := pingDB(cfg, ctx); err != nil { + return c.JSON(http.StatusServiceUnavailable, map[string]string{ + "status": "unhealthy", + "error": "database unavailable", + }) + } + return c.JSON(http.StatusOK, map[string]string{ + "status": "healthy", + "database": "connected", + }) + }) +} diff --git a/internal/router/helpers.go b/internal/router/helpers.go new file mode 100644 index 0000000..ced0eee --- /dev/null +++ b/internal/router/helpers.go @@ -0,0 +1,97 @@ +package router + +import ( + "context" + "time" + + "bookhoard/internal/handlers" + "bookhoard/templates" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/labstack/echo/v4" +) + +func getTemplateUserWithTheme(c echo.Context, cfg *Config) (templates.User, error) { + userID := c.Get("user_id").(string) + userEmail := c.Get("user_email").(string) + userUsername := c.Get("user_username").(string) + userRole := c.Get("user_role").(string) + + userUUID, err := uuid.Parse(userID) + if err != nil { + return templates.User{}, err + } + + userDB, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID)) + if err != nil { + return templates.User{}, err + } + + userTheme := "tokyo-night" + if userDB.Theme.Valid { + userTheme = userDB.Theme.String + } + + return templates.User{ + ID: userID, + Email: userEmail, + Username: userUsername, + Role: userRole, + Theme: userTheme, + }, nil +} + +func convertDevices(deviceInfos []handlers.DeviceInfo) []templates.DeviceData { + result := make([]templates.DeviceData, len(deviceInfos)) + for i, d := range deviceInfos { + lastSync := "" + if d.LastSync != nil { + lastSync = d.LastSync.Format(time.RFC3339) + } + lastSeen := "" + if d.LastSeen != nil { + lastSeen = d.LastSeen.Format(time.RFC3339) + } + result[i] = templates.DeviceData{ + ID: d.ID.String(), + DeviceName: d.DeviceName, + DeviceType: d.DeviceType, + SyncEnabled: d.SyncEnabled, + LastSync: lastSync, + LastSeen: lastSeen, + } + } + return result +} + +func convertPending(pending []map[string]interface{}) []templates.PendingRegistrationData { + result := make([]templates.PendingRegistrationData, len(pending)) + for i, p := range pending { + result[i] = templates.PendingRegistrationData{ + RegistrationID: p["registration_id"].(string), + DeviceName: p["device_name"].(string), + DeviceType: p["device_type"].(string), + ExpiresAt: p["expires_at"].(string), + } + } + return result +} + +func pingDB(cfg *Config, ctx context.Context) error { + if cfg.DBPool != nil { + if pool, ok := cfg.DBPool.(*pgxpool.Pool); ok { + return pool.Ping(ctx) + } + } + return nil +} + +func parseUUID(s string) (uuid.UUID, error) { + return uuid.Parse(s) +} + +func uuidToPGType(u uuid.UUID) pgtype.UUID { + return pgtype.UUID{Bytes: [16]byte(u), Valid: true} +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..5534d52 --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,125 @@ +package router + +import ( + "bookhoard/internal/config" + "bookhoard/internal/database" + "bookhoard/internal/handlers" + "bookhoard/internal/middleware" + ratelimit "bookhoard/internal/middleware" + "bookhoard/internal/sync" + "log" + "time" + + "github.com/go-playground/validator/v10" + "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 + KOReaderHandler *handlers.KOReaderHandler + WSHandler *handlers.WSHandler + ConflictHandler *handlers.ConflictHandler + AnalyticsHandler *handlers.AnalyticsHandler + QueueHandler *handlers.QueueHandler + CollectionHandler *handlers.CollectionHandler + OPDSHandler *handlers.OPDSHandler + ConnManager *sync.ConnectionManager + QueueProcessor *sync.SyncQueueProcessor + DeviceAuthMiddleware *middleware.DeviceAuthMiddleware + LoginTracker *ratelimit.LoginAttemptTracker +} + +// RegisterRoutes registers all application routes +func RegisterRoutes(cfg *Config) { + 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 route groups + registerAuthRoutes(cfg, rateLimitMiddleware) + registerLibraryRoutes(cfg) + registerDeviceRoutes(cfg) + registerSyncRoutes(cfg) + registerMediaRoutes(cfg) + registerConflictRoutes(cfg) + registerAnalyticsRoutes(cfg) + registerQueueRoutes(cfg) + registerOPDSRoutes(cfg) + registerWebSocketRoutes(cfg) + registerFrontendRoutes(cfg) + registerDocumentationRoutes(cfg) +} + +// Stub functions - will be implemented incrementally +func registerLibraryRoutes(cfg *Config) { + // TODO: Implement in library.go +} + +func registerDeviceRoutes(cfg *Config) { + // TODO: Implement in device.go +} + +func registerSyncRoutes(cfg *Config) { + // TODO: Implement in sync.go +} + +func registerMediaRoutes(cfg *Config) { + // TODO: Implement in media.go +} + +func registerConflictRoutes(cfg *Config) { + // TODO: Implement in conflicts.go +} + +func registerAnalyticsRoutes(cfg *Config) { + // TODO: Implement in analytics.go +} + +func registerQueueRoutes(cfg *Config) { + // TODO: Implement in queue.go +} + +func registerOPDSRoutes(cfg *Config) { + // TODO: Implement in opds.go +} + +func registerWebSocketRoutes(cfg *Config) { + // TODO: Implement in websocket.go +}