Files
mangaBookmark/backend/internal/api/handlers.go
T
sulthan 64c27fe896 feat(cover): acquire a Series Cover at creation (#59)
A Reader who bookmarks a Series nobody holds yet no longer waits out the
poll queue for its artwork: the first Bookmark to create a Series fires
Store.OnSeriesCreated, and latest.Acquirer turns that into a single
series-page fetch yielding both the Latest Chapter and the cover URL. The
bytes are fetched through the gated cover fetcher and stored
content-addressed, so the wire carries an absolute URL on this
deployment's own origin (ADR-0007) - never a third-party address and
never one that 404s.

- series.cover_address (migration 0009) splits the third-party source
  address the bytes came from (series.cover) from the content address
  they are stored under. A blank cover_address is what "no Cover yet"
  means, so the wire field is empty until real bytes exist.
- GET /covers/{address} serves the bytes publicly and uncredentialed,
  immutable-cached; the address is gated by a 64-hex pattern and
  cross-checked against a pure function of itself before any filesystem
  read.
- Client-sent cover values are decoded and discarded permanently: the
  cover columns are absent from Upsert's INSERT and its DO UPDATE, so no
  request value can reach the shared Series row (extends ADR-0003's
  "ignored after creation" to "ignored always", keeps ADR-0004's flat
  wire so installed userscripts keep working).
- Acquisition is asynchronous and log-and-drop: the Reader's write
  neither blocks on nor fails with a third-party Site. It is bounded by
  a two-slot semaphore, cancelled at shutdown, and stamps
  latest_checked_at so the poller does not refetch the same page a tick
  later.
- store.CoverContentType canonicalises comix's non-standard "image/jpg"
  to "image/jpeg", so one image cannot land under two spellings.
- PUBLIC_BASE_URL is a new required setting; Open rejects anything that
  is not an absolute http(s) origin, since a bare hostname would start
  cleanly and emit addresses no browser can load.

Verified against a live backend: bookmarking a comix series produced a
280x420 JPEG served from /covers/<sha256> with the immutable cache
header, and the web UI card renders that address.
2026-08-10 02:31:13 +07:00

159 lines
4.9 KiB
Go

package api
import (
"encoding/json"
"log"
"net/http"
"strings"
"time"
"bookmarkmanager/backend/internal/httpmw"
"bookmarkmanager/backend/internal/store"
)
// Handler serves the userscript-facing JSON bookmark API.
type Handler struct {
Store *store.Store
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if v != nil {
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("encode response: %v", err)
}
}
}
// List returns all bookmarks of the acting Reader. GET /bookmarks
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
items, err := h.Store.List(httpmw.ReaderID(r))
if err != nil {
log.Printf("list: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, items)
}
// Put upserts one bookmark. PUT /bookmarks/{key}
func (h *Handler) Put(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
if key == "" {
http.Error(w, "missing key", http.StatusBadRequest)
return
}
var b store.Bookmark
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&b); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
// A body may carry a cover, and it is discarded here rather than
// rejected: every installed userscript still sends one, and ADR-0004's
// compatibility argument depends on those scripts continuing to work. The
// Cover is acquired server-side (ADR-0007), so the field is permanently
// inert - not pending removal, and not a value any later code should
// start reading.
b.Cover = ""
// Path key is authoritative; derive site/series_id from it when the body
// omits them so the stored row is always self-consistent.
b.Key = key
if b.Site == "" || b.SeriesID == "" {
if site, series, ok := strings.Cut(key, ":"); ok {
if b.Site == "" {
b.Site = site
}
if b.SeriesID == "" {
b.SeriesID = series
}
}
}
// An empty status is "no opinion" and Upsert keeps the stored bucket.
// Finishing a series is a web-UI decision, so the JSON API refuses it
// rather than trusting every client to leave it alone.
switch b.Status {
case "", store.StatusReading, store.StatusArchived:
case store.StatusFinished:
http.Error(w, "status "+store.StatusFinished+" can only be set from the web UI",
http.StatusBadRequest)
return
default:
http.Error(w, "invalid status", http.StatusBadRequest)
return
}
// Same rule as status: empty means "keep the stored value". An unknown
// value is a client bug, not something to silently coerce to manga.
switch b.Kind {
case "", store.KindManga, store.KindNovel:
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid kind"})
return
}
// Candidate timestamp, not a decision: Upsert keeps the stored one unless
// reading progress actually moved. Any client value is ignored.
b.UpdatedAt = time.Now().UnixMilli()
stored, err := h.Store.Upsert(httpmw.ReaderID(r), b)
if err != nil {
log.Printf("upsert: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Echo the stored row: clients adopt this as their cached copy, so it must
// carry the authoritative updated_at rather than the candidate above.
writeJSON(w, http.StatusOK, stored)
}
// Delete removes one bookmark. DELETE /bookmarks/{key}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
if key == "" {
http.Error(w, "missing key", http.StatusBadRequest)
return
}
if err := h.Store.Delete(httpmw.ReaderID(r), key); err != nil {
log.Printf("delete: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// Healthz answers the unauthenticated liveness check. GET /healthz
func Healthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
// Cover serves stored cover bytes. GET /covers/{address}
//
// Public on purpose: the userscript renders these on Sites the deployment
// does not control, where no credential of ours may be sent, and the address
// is the SHA-256 of a URL the Site already publishes (ADR-0007). An unknown
// address is a 404 rather than an error - "no Cover yet" is a normal state,
// and the clients fall back to their placeholder.
func (h *Handler) Cover(w http.ResponseWriter, r *http.Request) {
body, contentType, ok, err := h.Store.CoverByAddress(r.PathValue("address"))
if err != nil {
log.Printf("cover: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", contentType)
// Content-addressed, so the bytes at this URL can never change. Public
// rather than private: no credential gates the route.
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
_, _ = w.Write(body)
}