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.
186 lines
6.3 KiB
Go
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)
|
|
}
|