Vellum/internal/modules/comics/handlers.go
mithral c3b7db7114 Phase 4: CBZ comics module and reader
- comics: reads pages straight out of the archive on demand. No extraction
  pass, so a 200-page CBZ is not materialised for a reader who views three
  pages. Pages are read into memory and served via http.ServeContent, which
  gives Range for free.
- natural page ordering: page2 sorts before page10. Lexical ordering shuffles
  a numbered archive, which is the difference between a readable book and a
  scrambled one.
- skips __MACOSX and dotfiles; rejects archives with no images.
- cover is the first page.
- media.Item gained ModTime (from the backing file) so page responses carry a
  Last-Modified and support conditional GETs.
- web: ComicReader with a bounded prefetch window (two pages each way), page
  jump, keyboard nav, and progress stored as a page number.

Verified: 3-page CBZ served in natural order, out-of-range 404, cover from
page 1, progress round-trip. svelte-check 0/0.
2026-10-09 21:07:59 -04:00

115 lines
3.4 KiB
Go

package comics
import (
"bytes"
"database/sql"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/httpx"
"github.com/mithral/vellum/internal/media"
"github.com/mithral/vellum/internal/stream"
)
// Handlers serves comic-specific endpoints.
type Handlers struct {
db *sql.DB
log *slog.Logger
auth *auth.Authenticator
}
// NewHandlers builds the comic HTTP layer.
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers {
return &Handlers{db: db, log: log, auth: a}
}
// Register mounts the routes.
func (h *Handlers) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/items/{id}/pages", h.auth.Require(h.handleListPages))
mux.Handle("GET /api/v1/items/{id}/pages/{page}", h.auth.Require(h.handlePage))
}
// handleListPages returns the archive's page list in reading order.
func (h *Handlers) handleListPages(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
pages, err := ListPages(it.AbsPath())
if err != nil {
h.log.Warn("list pages", "item", it.ID, "err", err)
httpx.WriteError(w, http.StatusUnprocessableEntity, "could not read this archive")
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"pages": pages, "count": len(pages)})
}
// handlePage streams one page image.
func (h *Handlers) handlePage(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
idx, err := strconv.Atoi(r.PathValue("page"))
if err != nil || idx < 1 {
httpx.WriteError(w, http.StatusBadRequest, "invalid page number")
return
}
pages, err := ListPages(it.AbsPath())
if err != nil {
httpx.WriteError(w, http.StatusUnprocessableEntity, "could not read this archive")
return
}
if idx > len(pages) {
httpx.WriteError(w, http.StatusNotFound, "page out of range")
return
}
data, err := ReadPage(it.AbsPath(), pages[idx-1].Name)
if err != nil {
h.log.Error("read page", "item", it.ID, "page", idx, "err", err)
httpx.WriteError(w, http.StatusInternalServerError, "could not read the page")
return
}
// Pages are immutable; let the browser keep them.
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Header().Set("Content-Type", stream.ContentType(pages[idx-1].Name))
http.ServeContent(w, r, pages[idx-1].Name, it.ModTime, bytes.NewReader(data))
}
// authorized loads an item, confirms access, and checks it is a comic.
func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.User) (media.Item, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
httpx.WriteError(w, http.StatusBadRequest, "invalid item id")
return media.Item{}, false
}
it, err := media.LoadItem(r.Context(), h.db, id)
if errors.Is(err, media.ErrItemNotFound) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return media.Item{}, false
}
if err != nil {
h.log.Error("load item", "err", err)
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
return media.Item{}, false
}
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID)
if err != nil || !allowed {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return media.Item{}, false
}
if it.Kind != Kind {
httpx.WriteError(w, http.StatusBadRequest, "item is not a comic")
return media.Item{}, false
}
return it, true
}