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) }