diff --git a/cmd/server/main.go b/cmd/server/main.go index 3ff3b24..e92e630 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -6,13 +6,13 @@ import ( "bookmann/internal/handlers" "bookmann/internal/middleware" ratelimit "bookmann/internal/middleware" + "bookmann/internal/services" "bookmann/internal/sync" "bookmann/templates" "bytes" "context" "log" "net/http" - "strings" "time" "github.com/go-playground/validator/v10" @@ -94,8 +94,13 @@ func main() { koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor) wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware) conflictHandler := handlers.NewConflictHandler(queries, connManager) + analyticsHandler := handlers.NewAnalyticsHandler(queries) queueHandler := handlers.NewQueueHandler(queries, queueProcessor) + // Create conversion service for EPUB→KEPUB conversion + conversionService := services.NewConversionService(queries, "/var/bookmann/cache/kepub") + opdsHandler := handlers.NewOPDSHandler(queries, conversionService) + e := echo.New() // Set up validator @@ -247,6 +252,12 @@ func main() { koboSync.GET("/v1/initialization", deviceAuthMiddleware.Authenticate(koboHandler.Initialization)) koboSync.POST("/sync-from-server", deviceAuthMiddleware.Authenticate(koboHandler.SyncFromServer)) + // Book matching and unlinked book resolution routes + sync := protected.Group("/sync") + sync.POST("/bulk-link-books", h.BulkLinkBooks) + sync.POST("/auto-link-books", h.AutoLinkBooks) + sync.GET("/unlinked-books/:id/suggestions", h.GetUnlinkedBookSuggestions) + // Media item routes (download and shelf management) mediaHandler := handlers.NewMediaHandler(queries) e.GET("/api/books/:uuid/download", mediaHandler.DownloadBook) @@ -255,6 +266,11 @@ func main() { protected.DELETE("/devices/:id/shelves", mediaHandler.RemoveFromShelf) protected.DELETE("/devices/:id/shelves/clear", mediaHandler.ClearShelf) + // Bulk book operations (protected - require user auth) + books := protected.Group("/books") + books.POST("/bulk-delete", mediaHandler.HandleBulkDelete) + books.POST("/bulk-update", mediaHandler.HandleBulkUpdate) + // Device management routes (protected - require user auth) devices := protected.Group("/devices") devices.GET("", deviceHandler.ListDevices) @@ -272,6 +288,14 @@ func main() { conflicts.POST("/:id/resolve", conflictHandler.ResolveConflict) conflicts.DELETE("/:id", conflictHandler.DeleteConflict) conflicts.POST("/dismiss-all", conflictHandler.DismissAllResolved) + conflicts.POST("/bulk-resolve", conflictHandler.BulkResolveConflicts) + conflicts.POST("/bulk-dismiss", conflictHandler.BulkDismissConflicts) + + // Analytics routes (protected - require user auth) + analytics := protected.Group("/analytics") + analytics.GET("/reading-stats", analyticsHandler.GetReadingStats) + analytics.GET("/device-usage", analyticsHandler.GetDeviceUsage) + analytics.GET("/popular-books", analyticsHandler.GetPopularBooks) // Sync queue management routes (protected - require user auth) queue := protected.Group("/queue") @@ -288,6 +312,15 @@ func main() { // WebSocket endpoint for real-time sync e.GET("/ws/sync", wsHandler.HandleWebSocket) + // OPDS routes (public - device authentication optional) + opds := e.Group("/opds/devices") + opds.GET("/:deviceId/catalog", opdsHandler.GetDeviceCatalog) + opds.GET("/:deviceId/search", opdsHandler.SearchDeviceCatalog) + opds.GET("/:deviceId/nav", opdsHandler.GetDeviceNavigation) + opds.GET("/:deviceId/download/:bookId", opdsHandler.DownloadBook) + opds.GET("/:deviceId/cover/:bookId", opdsHandler.GetCoverImage) + opds.GET("/:deviceId/formats/:bookId", opdsHandler.ListFormats) + // Static files e.Static("/static", "web/static") @@ -325,226 +358,15 @@ func main() { return c.HTML(http.StatusOK, buf.String()) }) - // Direct /bookshelf route (protected) - e.GET("/bookshelf", func(c echo.Context) error { - tokenString := c.Request().Header.Get("Authorization") - if tokenString != "" && strings.HasPrefix(tokenString, "Bearer ") { - tokenString = tokenString[7:] - } else { - // Check for token in cookie - cookie, err := c.Cookie("token") - if err != nil { - return c.Redirect(http.StatusFound, "/login") - } - tokenString = cookie.Value - } - - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { - return []byte(cfg.JWTSecret), nil - }) - - if err != nil || !token.Valid { - return c.Redirect(http.StatusFound, "/login") - } - - claims := token.Claims.(jwt.MapClaims) - user := templates.User{ - ID: claims["user_id"].(string), - Email: claims["user_email"].(string), - Username: claims["user_username"].(string), - Role: claims["user_role"].(string), + // Analytics route (protected) - SSR version + protected.GET("/analytics", func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, queries) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user") } var buf bytes.Buffer - err = templates.BookShelf(user).Render(c.Request().Context(), &buf) - if err != nil { - return err - } - return c.HTML(http.StatusOK, buf.String()) - }) - - // Dashboard route (protected) - keep for backward compatibility - protected.GET("/dashboard", func(c echo.Context) 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) - - user := templates.User{ - ID: userID, - Email: userEmail, - Username: userUsername, - Role: userRole, - } - - var buf bytes.Buffer - err := templates.Dashboard(user).Render(c.Request().Context(), &buf) - if err != nil { - return err - } - return c.HTML(http.StatusOK, buf.String()) - }) - - dummyUser := templates.User{ID: "", Username: "Admin", Email: "admin@example.com"} - - // Routes - e.GET("/", func(c echo.Context) error { - loggedIn := false - var buf bytes.Buffer - err := templates.Index(loggedIn).Render(c.Request().Context(), &buf) - if err != nil { - return err - } - return c.HTML(http.StatusOK, buf.String()) - }) - - 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()) - }) - - e.GET("/admin", func(c echo.Context) error { - var buf bytes.Buffer - err := templates.Admin(dummyUser).Render(c.Request().Context(), &buf) - if err != nil { - return err - } - return c.HTML(http.StatusOK, buf.String()) - }) - - e.GET("/admin/", func(c echo.Context) error { - var buf bytes.Buffer - err := templates.Admin(dummyUser).Render(c.Request().Context(), &buf) - if err != nil { - return err - } - return c.HTML(http.StatusOK, buf.String()) - }) - - e.GET("/admin/profile", func(c echo.Context) error { - var buf bytes.Buffer - err := templates.AdminProfile(dummyUser).Render(c.Request().Context(), &buf) - if err != nil { - return err - } - return c.HTML(http.StatusOK, buf.String()) - }) - - e.GET("/admin/library", func(c echo.Context) error { - var buf bytes.Buffer - err := templates.AdminLibrary(dummyUser).Render(c.Request().Context(), &buf) - if err != nil { - return err - } - return c.HTML(http.StatusOK, buf.String()) - }) - - // Device Management route (protected) - SSR version - protected.GET("/devices", func(c echo.Context) 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) - - user := templates.User{ - ID: userID, - Email: userEmail, - Username: userUsername, - Role: userRole, - } - - // Fetch devices for SSR (uses new helper method) - devices, err := deviceHandler.GetDevicesData(c) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading devices") - } - - // Convert to template format - deviceData := make([]templates.DeviceData, len(devices)) - for i, device := range devices { - var lastSync, lastSeen string - if device.LastSync != nil { - lastSync = device.LastSync.Format("2006-01-02T15:04:05Z07:00") - } - if device.LastSeen != nil { - lastSeen = device.LastSeen.Format("2006-01-02T15:04:05Z07:00") - } - - deviceData[i] = templates.DeviceData{ - ID: device.ID.String(), - DeviceName: device.DeviceName, - DeviceType: device.DeviceType, - SyncEnabled: device.SyncEnabled, - AutoSync: device.AutoSync, - SyncFrequency: device.SyncFrequency, - LastSync: lastSync, - LastSeen: lastSeen, - } - } - - // Fetch pending registrations for SSR (uses new helper) - pendingRegs, err := deviceHandler.GetPendingRegistrationsData(c) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading pending registrations") - } - - // Convert to template format - pendingData := make([]templates.PendingRegistrationData, len(pendingRegs)) - for i, reg := range pendingRegs { - expiresAt := reg["expires_at"].(time.Time) - pendingData[i] = templates.PendingRegistrationData{ - RegistrationID: reg["registration_id"].(string), - DeviceName: reg["device_name"].(string), - DeviceType: reg["device_type"].(string), - ExpiresAt: expiresAt.Format("2006-01-02T15:04:05Z07:00"), - } - } - - // Render template WITH data (SSR) - var buf bytes.Buffer - err = templates.Devices(user, deviceData, pendingData).Render(c.Request().Context(), &buf) - if err != nil { - return err - } - return c.HTML(http.StatusOK, buf.String()) - }) - - // Conflicts route (protected) - SSR version - protected.GET("/conflicts", func(c echo.Context) 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) - - user := templates.User{ - ID: userID, - Email: userEmail, - Username: userUsername, - Role: userRole, - } - - // Fetch conflicts for SSR (uses existing handler method) - apiConflicts, total, unresolved, err := conflictHandler.GetConflictsData(c) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading conflicts") - } - - // Render template WITH data (SSR) - use apiConflicts directly - var buf bytes.Buffer - err = templates.Conflicts(user, apiConflicts, total, unresolved).Render(c.Request().Context(), &buf) + err = templates.Analytics(user).Render(c.Request().Context(), &buf) if err != nil { return err } @@ -660,6 +482,7 @@ func main() { // Collections management route (protected) - SSR version collectionHandler := handlers.NewCollectionHandler(queries, connManager) collections := protected.Group("/collections") + collections.POST("/bulk-add-books", collectionHandler.HandleBulkAddBooks) collections.GET("", func(c echo.Context) error { user, err := getTemplateUserWithTheme(c, queries) if err != nil {