- 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.
115 lines
3.4 KiB
Go
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
|
|
}
|