Files
bookhoard/internal/router/reader.go
T
john-okeefe 065099cfc2 fix(reader): URL-encode file paths and JSON-encode init config to fix comics/manga loading
The reader failed to load comics and manga (and any file with special
characters in its path) for two reasons:

1. FileURL was built with raw fmt.Sprintf instead of ResolveMediaURL,
   so characters like '#' in paths (e.g. 'Annual #2') were interpreted
   as URL fragments, truncating the path and causing 404s.

2. The Alpine x-init expression used raw string interpolation for config
   values, so apostrophes in paths (e.g. "I'll Use My Appraisal Skill")
   broke JavaScript parsing with 'Unexpected identifier'.

Fix by using utils.ResolveMediaURL for proper URL path encoding and
json.Marshal for the initReader config to safely escape all special
characters.
2026-04-23 20:39:36 -04:00

186 lines
6.3 KiB
Go

package router
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
"bookhoard/internal/utils"
"bookhoard/templates"
"bytes"
"errors"
"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 && !errors.Is(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])
libUUID, _ := uuid.FromBytes(mediaItem.LibraryID.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),
FormatGroup: mediaItem.FormatGroup,
MangaType: textToString(mediaItem.MangaType),
ReadingDirection: textToString(mediaItem.ReadingDirection),
LibraryID: libUUID.String(),
FileURL: utils.ResolveMediaURL(mediaItem.LibraryID, pgtype.Text{String: mediaItem.FilePath, Valid: true}),
}
// 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 {
val := int(b.PageNumber.Int32)
pageNumber = &val
}
var chapterNumber *int
if b.ChapterNumber.Valid {
val := int(b.ChapterNumber.Int32)
chapterNumber = &val
}
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)
}