Files
bookhoard/internal/router/reader.go
T
john-okeefe e7ce0cbe94 Implement backend reader infrastructure with SSR route and library access control
- Fix function signatures in reader.go (c echo.Context -> c *echo.Context)
- Replace non-existent UserHasLibraryAccess with GetUserVisibleLibraries pattern
- Implement SSR reader route in router/reader.go with proper access control
- Add inline library access checking following existing codebase patterns
- Fix ReadingProgress struct to use LastReadAt instead of CreatedAt/UpdatedAt
- Ensure all reader endpoints use consistent library access validation

This provides the backend foundation for the reader feature with proper
access control and SSR rendering capabilities.
2026-04-03 22:29:01 -04:00

178 lines
5.9 KiB
Go

package router
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
"bookhoard/templates"
"bytes"
"net/http"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
func registerReaderRoutes(cfg *Config) {
e := cfg.Echo
// Create reader service and handler
readerService := services.NewReaderService(cfg.Queries, cfg.Worker)
cfg.ReaderHandler = handlers.NewReaderHandler(
cfg.Queries,
services.NewLibraryService(cfg.Queries),
readerService,
cfg.Worker,
)
jwtMiddleware := createJWTMiddleware(cfg)
// Reader page routes (SSR + API)
reader := e.Group("/readers", jwtMiddleware)
// Reader page (SSR)
reader.GET("/:mediaItemId", func(c *echo.Context) error {
// Get user
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
// Parse mediaItemID
mediaItemID := c.Param("mediaItemId")
parsedUUID, err := uuid.Parse(mediaItemID)
if err != nil {
return renderErrorPage(c, "Invalid media item ID", "invalid_id")
}
// Fetch media item (directly, no handler)
mediaItem, err := cfg.Queries.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
if err != nil {
return renderErrorPage(c, "Media item not found", "not_found")
}
// Check library access (using pattern from opds.go)
userUUID, _ := uuid.Parse(user.ID)
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
if err != nil {
return renderErrorPage(c, "Failed to check access", "access_error")
}
visible := false
for _, lib := range libraries {
if lib.ID == mediaItem.LibraryID {
visible = true
break
}
}
if !visible {
return renderErrorPage(c, "Access denied", "access_denied")
}
// Get reading progress
var progress database.ReadingProgress
progress, err = cfg.Queries.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
UserID: uuidToPGType(userUUID),
})
if err != nil && err != pgx.ErrNoRows {
progress = database.ReadingProgress{}
}
// Get bookmarks
bookmarks, _ := cfg.Queries.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
UserID: uuidToPGType(userUUID),
})
// Convert to template types
mediaUUID, _ := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
metadata := templates.ReaderMetadata{
MediaItemID: mediaUUID.String(),
Title: mediaItem.Title,
Author: textToString(mediaItem.Author),
CoverImagePath: textToString(mediaItem.CoverImagePath),
LibraryType: mediaItem.FormatGroup,
MimeType: textToString(mediaItem.MimeType),
FilePath: mediaItem.FilePath,
TotalPages: int(mediaItem.PageCount.Int32),
ChapterCount: int(mediaItem.ChapterCount.Int32),
}
// Progress conversion (inline)
progressUUID, _ := uuid.FromBytes(progress.ID.Bytes[0:16])
progressMediaUUID, _ := uuid.FromBytes(progress.MediaItemID.Bytes[0:16])
progressUserUUID, _ := uuid.FromBytes(progress.UserID.Bytes[0:16])
templateProgress := templates.ReadingProgress{
ID: progressUUID.String(),
MediaItemID: progressMediaUUID.String(),
UserID: progressUserUUID.String(),
CurrentPage: int(progress.CurrentPage.Int32),
TotalPages: int(progress.TotalPages.Int32),
Percentage: progress.Percentage.Float64,
EpubCfi: textToString(progress.Epubcfi),
LastReadAt: progress.LastReadAt.Time,
}
// Bookmarks conversion (inline, with loop)
templateBookmarks := make([]templates.Bookmark, len(bookmarks))
for i, b := range bookmarks {
bookmarkUUID, _ := uuid.FromBytes(b.ID.Bytes[0:16])
bookmarkMediaUUID, _ := uuid.FromBytes(b.MediaItemID.Bytes[0:16])
bookmarkUserUUID, _ := uuid.FromBytes(b.UserID.Bytes[0:16])
var pageNumber *int
if b.PageNumber.Valid {
pn := int(b.PageNumber.Int32)
pageNumber = &pn
}
var chapterNumber *int
if b.ChapterNumber.Valid {
cn := int(b.ChapterNumber.Int32)
chapterNumber = &cn
}
templateBookmarks[i] = templates.Bookmark{
ID: bookmarkUUID.String(),
MediaItemID: bookmarkMediaUUID.String(),
UserID: bookmarkUserUUID.String(),
PageNumber: pageNumber,
ChapterNumber: chapterNumber,
CfiPosition: textToString(b.CfiPosition),
Title: b.Title,
Position: textToString(b.Position),
Notes: textToString(b.Notes),
CreatedAt: b.CreatedAt.Time,
}
}
// 8. Render template
var buf bytes.Buffer
err = templates.Reader(user, metadata, templateProgress, templateBookmarks).Render(c.Request().Context(), &buf)
if err != nil {
return renderErrorPage(c, "Error rendering reader", "render_error")
}
return c.HTML(http.StatusOK, buf.String())
})
// Content serving (lazy-loaded pages)
reader.GET("/:mediaItemId/pages/:pageNumber", cfg.ReaderHandler.GetPage)
// Chapter metadata
reader.GET("/:mediaItemId/chapters", cfg.ReaderHandler.GetChapters)
// Panel data (comics/manga)
reader.GET("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.GetPanels)
reader.PUT("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.UpdatePanels)
// PDF outline/TOC
reader.GET("/:mediaItemId/outline", cfg.ReaderHandler.GetPDFOutline)
// PDF thumbnails (for mini-map)
reader.GET("/:mediaItemId/thumbnails/:pageNumber", cfg.ReaderHandler.GetPDFThumbnail)
// Reading speed
reader.GET("/:mediaItemId/reading-speed", cfg.ReaderHandler.GetReadingSpeed)
reader.POST("/:mediaItemId/reading-speed", cfg.ReaderHandler.UpdateReadingSpeed)
// Dictionary lookup
reader.GET("/dictionary/:word", cfg.ReaderHandler.LookupWord)
// Reader settings
reader.GET("/settings", cfg.ReaderHandler.GetSettings)
reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings)
}