package abs import ( "encoding/json" "io" "log/slog" "math" "net/http" "strconv" "github.com/go-chi/chi/v5" ) // bookmarkBody is the JSON body for POST and PATCH // /me/item/{itemId}/bookmark. Time is a pointer so we can distinguish // missing (→ 400) from the literal 0.0. type bookmarkBody struct { Title string `json:"title"` Time *float64 `json:"time"` } // handleUpsertBookmark backs both POST (reason="bookmark_created") and // PATCH (reason="bookmark_updated") /me/item/{itemId}/bookmark. Both // share the exact same upsert semantics — only the realtime event // reason differs. func (h *Handler) handleUpsertBookmark(reason string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { a, ok := absAuthFrom(r) if !ok || a.UserID == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if h.deps.BookmarkStore == nil { http.Error(w, "bookmark store unavailable", http.StatusServiceUnavailable) return } itemID := chi.URLParam(r, "itemId") if itemID == "" { http.Error(w, "itemId required", http.StatusBadRequest) return } // 1 MiB body cap — matches handleStandaloneLogin. var body bookmarkBody dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) if err := dec.Decode(&body); err != nil { http.Error(w, "invalid body", http.StatusBadRequest) return } if body.Time == nil || math.IsNaN(*body.Time) { http.Error(w, "time required", http.StatusBadRequest) return } // Item validation: avoid orphan bookmark rows whose item no // longer exists. Skipped on DELETE (see handleDeleteBookmark). access, err := h.accessFilterForAuth(r.Context(), a) if err != nil { http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden) return } item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID, access) if err != nil { slog.ErrorContext(r.Context(), "abs bookmark item lookup failed", "component", "audiobooks", "err", err, "user", a.UserID, "item", itemID) http.Error(w, "item lookup failed", http.StatusInternalServerError) return } if item == nil { http.Error(w, "item not found", http.StatusNotFound) return } bm, err := h.deps.BookmarkStore.Upsert(r.Context(), a.UserID, a.ProfileID, itemID, *body.Time, body.Title) if err != nil { slog.ErrorContext(r.Context(), "abs bookmark upsert failed", "component", "audiobooks", "err", err, "user", a.UserID, "item", itemID) http.Error(w, "bookmark persist failed", http.StatusInternalServerError) return } h.publish(a.UserID, "user_updated", map[string]any{ "reason": reason, "bookmark": bookmarkToABS(bm), }) writeBookmarkList(w, r, h, a.UserID, a.ProfileID, itemID) } } // handleDeleteBookmark — DELETE /me/item/{itemId}/bookmark/{time}. // // Idempotent: returns 200 with the caller's current bookmark list, // whether or not the (item, time) row existed. Crucially, this means // a DELETE against another user's bookmark returns the caller's own // (empty-or-other) list — no enumeration vector. // // Item validation is intentionally skipped: a bookmark whose item was // just deleted should still be removable. (Upsert keeps validation // because it would create a new orphan row.) func (h *Handler) handleDeleteBookmark(w http.ResponseWriter, r *http.Request) { a, ok := absAuthFrom(r) if !ok || a.UserID == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if h.deps.BookmarkStore == nil { http.Error(w, "bookmark store unavailable", http.StatusServiceUnavailable) return } itemID := chi.URLParam(r, "itemId") if itemID == "" { http.Error(w, "itemId required", http.StatusBadRequest) return } t, ok := parseBookmarkTime(chi.URLParam(r, "time")) if !ok { http.Error(w, "time required", http.StatusBadRequest) return } // Snapshot the pre-delete row so the realtime payload carries the // title that just got removed (clients prefer this over a bare ID). var pre Bookmark if rows, err := h.deps.BookmarkStore.List(r.Context(), a.UserID, a.ProfileID, itemID); err == nil { for _, b := range rows { if b.Time == t { pre = b break } } } if err := h.deps.BookmarkStore.Delete(r.Context(), a.UserID, a.ProfileID, itemID, t); err != nil { slog.ErrorContext(r.Context(), "abs bookmark delete failed", "component", "audiobooks", "err", err, "user", a.UserID, "item", itemID) http.Error(w, "bookmark delete failed", http.StatusInternalServerError) return } // Only publish when the row actually existed (pre.ID is empty // otherwise). Avoids notifying other devices about a phantom delete. if pre.ID != "" { h.publish(a.UserID, "user_updated", map[string]any{ "reason": "bookmark_deleted", "bookmark": bookmarkToABS(pre), }) } writeBookmarkList(w, r, h, a.UserID, a.ProfileID, itemID) } // parseBookmarkTime parses the {time} URL parameter on DELETE // /me/item/{itemId}/bookmark/{time}. Returns (0, false) on parse // failure. func parseBookmarkTime(s string) (float64, bool) { if s == "" { return 0, false } v, err := strconv.ParseFloat(s, 64) if err != nil || math.IsNaN(v) || math.IsInf(v, 0) { return 0, false } return v, true } // writeBookmarkList re-fetches the item's bookmarks and writes them as // the JSON response. On list-fetch failure after a successful mutation, // degrade to 200 + empty list + slog.Warn (the mutation already // committed; failing the response would mis-report the state). func writeBookmarkList(w http.ResponseWriter, r *http.Request, h *Handler, userID, profileID, itemID string) { rows, err := h.deps.BookmarkStore.List(r.Context(), userID, profileID, itemID) if err != nil { slog.WarnContext(r.Context(), "abs bookmark list after mutation failed", "component", "audiobooks", "err", err, "user", userID, "item", itemID) writeJSON(w, http.StatusOK, []any{}) return } out := make([]map[string]any, 0, len(rows)) for _, b := range rows { out = append(out, bookmarkToABS(b)) } writeJSON(w, http.StatusOK, out) }