package comics import ( "bytes" "database/sql" "errors" "log/slog" "net/http" "strconv" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/catalog" "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. // // The load and the access check both live in catalog.LoadFor; this package no // longer reimplements "may this user read this item". 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 := catalog.LoadFor(r.Context(), h.db, u, id) if errors.Is(err, catalog.ErrNotFound) { 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 } if it.Kind != Kind { httpx.WriteError(w, http.StatusBadRequest, "item is not a comic") return media.Item{}, false } return it, true }