package web import ( "encoding/json" "io" "log/slog" "net/http" "strconv" "strings" "pete/internal/storage" ) // maxStateBodyBytes caps read/bookmark request bodies. These carry a single id // and a boolean, so this is generous headroom. const maxStateBodyBytes = 1024 // maxStateIDs bounds how many ids /api/state will look up in one call. A page // renders a few dozen cards; this is well clear of that. const maxStateIDs = 500 // requireUser resolves the signed-in user or writes the appropriate error and // returns nil. It mirrors handlePrefs: 404 when auth is disabled entirely, 401 // for anonymous callers (the client's cue to stay on localStorage). func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) *SessionUser { if s.auth == nil { http.Error(w, "auth disabled", http.StatusNotFound) return nil } u := s.auth.userFromRequest(r) if u == nil { w.Header().Set("Content-Type", "application/json; charset=utf-8") http.Error(w, `{"error":"not signed in"}`, http.StatusUnauthorized) return nil } return u } // decodeStateBody reads a small JSON body into dst, writing a 400 on failure. func decodeStateBody(w http.ResponseWriter, r *http.Request, dst any) bool { return decodeStateBodyN(w, r, dst, maxStateBodyBytes) } // decodeStateBodyN is decodeStateBody with an explicit byte cap, for endpoints // (like push subscribe) whose payloads run larger than a single id + flag. func decodeStateBodyN(w http.ResponseWriter, r *http.Request, dst any, limit int64) bool { body, err := io.ReadAll(io.LimitReader(r.Body, limit+1)) if err != nil || int64(len(body)) > limit { http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) return false } if err := json.Unmarshal(body, dst); err != nil { http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest) return false } return true } // handleRead records or clears a story's read flag for the signed-in user. func (s *Server) handleRead(w http.ResponseWriter, r *http.Request) { u := s.requireUser(w, r) if u == nil { return } var req struct { ID int64 `json:"id"` Read bool `json:"read"` } if !decodeStateBody(w, r, &req) { return } if req.ID <= 0 { http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest) return } if err := storage.SetRead(u.Sub, req.ID, req.Read); err != nil { slog.Error("state: set read failed", "sub", u.Sub, "id", req.ID, "err", err) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // handleBookmark adds or removes a bookmark for the signed-in user. func (s *Server) handleBookmark(w http.ResponseWriter, r *http.Request) { u := s.requireUser(w, r) if u == nil { return } var req struct { ID int64 `json:"id"` On bool `json:"on"` } if !decodeStateBody(w, r, &req) { return } if req.ID <= 0 { http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest) return } if err := storage.SetBookmark(u.Sub, req.ID, req.On); err != nil { slog.Error("state: set bookmark failed", "sub", u.Sub, "id", req.ID, "err", err) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // handleState returns which of the given story ids are read and bookmarked, so // the client can paint a freshly rendered page in one round-trip. func (s *Server) handleState(w http.ResponseWriter, r *http.Request) { u := s.requireUser(w, r) if u == nil { return } ids := parseIDList(r.URL.Query().Get("ids")) read, bookmarked, err := storage.UserStoryState(u.Sub, ids) if err != nil { slog.Error("state: lookup failed", "sub", u.Sub, "err", err) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{ "read": keysOf(read), "bookmarked": keysOf(bookmarked), }) } // parseIDList turns "1,2,3" into a bounded, deduplicated slice of positive ids. func parseIDList(raw string) []int64 { if raw == "" { return nil } seen := make(map[int64]bool) out := make([]int64, 0) for _, part := range strings.Split(raw, ",") { id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64) if err != nil || id <= 0 || seen[id] { continue } seen[id] = true out = append(out, id) if len(out) >= maxStateIDs { break } } return out } // keysOf returns the keys of a set as a slice (order unspecified). func keysOf(m map[int64]bool) []int64 { out := make([]int64, 0, len(m)) for k := range m { out = append(out, k) } return out }