64c27fe896
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.
159 lines
4.9 KiB
Go
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)
|
|
}
|