fix: api rate limit
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
@@ -258,6 +259,17 @@ func (h *PostHandler) Unhide(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PostHandler) ListMine(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
limit, offset := parseLimitOffset(r)
|
||||
// Keyset path (scroll feed): a `before` cursor pages older posts. Without a
|
||||
// cursor we keep the offset+meta path the profile grid relies on.
|
||||
if beforeAt, beforeID, ok := parseCursor(r); ok {
|
||||
posts, err := h.svc.ListMineKeyset(r.Context(), uid, beforeAt, beforeID, limit)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, posts)
|
||||
return
|
||||
}
|
||||
posts, total, err := h.svc.ListMine(r.Context(), uid, limit, offset)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
@@ -719,6 +731,22 @@ func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
|
||||
// gateByBlock already resolved the owner, so list by id rather than
|
||||
// re-resolving the handle. Hidden posts never appear here (they live in
|
||||
// the owner's separate Hidden tab).
|
||||
//
|
||||
// Keyset path (scroll feed): a `before` cursor pages older posts. Without a
|
||||
// cursor we keep the offset+meta path the profile grid relies on.
|
||||
if beforeAt, beforeID, ok := parseCursor(r); ok {
|
||||
posts, err := h.posts.ListByUserKeyset(r.Context(), owner.ID, beforeAt, beforeID, limit)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := h.hydrateFeed(r, posts); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, posts)
|
||||
return
|
||||
}
|
||||
posts, total, err := h.posts.ListByUser(r.Context(), owner.ID, limit, offset)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
@@ -896,6 +924,22 @@ func parseLimitOffset(r *http.Request) (limit, offset int) {
|
||||
return limit, offset
|
||||
}
|
||||
|
||||
// parseCursor reads an optional keyset cursor for the scroll feed: `before` is
|
||||
// the last seen post's created_at (RFC3339Nano) and `before_id` its id. ok is
|
||||
// false when either is missing or unparseable (callers then page by offset).
|
||||
func parseCursor(r *http.Request) (beforeAt time.Time, beforeID string, ok bool) {
|
||||
at := strings.TrimSpace(r.URL.Query().Get("before"))
|
||||
id := strings.TrimSpace(r.URL.Query().Get("before_id"))
|
||||
if at == "" || id == "" {
|
||||
return time.Time{}, "", false
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, at)
|
||||
if err != nil {
|
||||
return time.Time{}, "", false
|
||||
}
|
||||
return t, id, true
|
||||
}
|
||||
|
||||
func paginationMeta(total, limit, offset int) *response.Meta {
|
||||
if limit == 0 {
|
||||
limit = 20
|
||||
|
||||
@@ -213,13 +213,41 @@ func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
// id DESC tiebreak must match ListByUserKeyset so the scroll feed's first
|
||||
// (offset) page and its keyset continuation agree on the order of
|
||||
// same-created_at posts — otherwise the keyset page re-returns already-shown
|
||||
// rows, the client's cursor never advances, and it loops forever.
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
WHERE p.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL
|
||||
ORDER BY p.created_at DESC, p.id
|
||||
ORDER BY p.created_at DESC, p.id DESC
|
||||
LIMIT $2 OFFSET $3`
|
||||
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||
}
|
||||
|
||||
// ListByUserKeyset returns the user's visible posts older than the cursor
|
||||
// (created_at, id), newest-first — keyset pagination for the Instagram-style
|
||||
// scroll feed. A zero beforeAt returns the newest page. No total is computed
|
||||
// (infinite scroll infers "more" from a full page, like FeedForViewer). The
|
||||
// (created_at, id) DESC ordering matches the tuple comparison so paging never
|
||||
// skips or repeats a row even when timestamps tie.
|
||||
func (r *PostRepo) ListByUserKeyset(ctx context.Context, userID string, beforeAt time.Time, beforeID string, limit int) ([]model.Post, error) {
|
||||
base := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
WHERE p.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL`
|
||||
if beforeAt.IsZero() {
|
||||
q := base + `
|
||||
ORDER BY p.created_at DESC, p.id DESC
|
||||
LIMIT $2`
|
||||
posts, _, err := r.collectPosts(ctx, 0, q, userID, limit)
|
||||
return posts, err
|
||||
}
|
||||
q := base + `
|
||||
AND (p.created_at < $2 OR (p.created_at = $2 AND p.id < $3))
|
||||
ORDER BY p.created_at DESC, p.id DESC
|
||||
LIMIT $4`
|
||||
posts, _, err := r.collectPosts(ctx, 0, q, userID, beforeAt, beforeID, limit)
|
||||
return posts, err
|
||||
}
|
||||
|
||||
// ListHiddenByUser returns the owner's hidden posts, newest first — the
|
||||
// /u/me "Hidden" tab. Owner-only (the caller enforces auth + scoping).
|
||||
func (r *PostRepo) ListHiddenByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||
|
||||
@@ -444,6 +444,33 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
// ListMineKeyset is the owner's scroll feed via keyset pagination (posts older
|
||||
// than the cursor, newest-first). Same hydration as ListMine; no total.
|
||||
func (s *PostService) ListMineKeyset(ctx context.Context, userID string, beforeAt time.Time, beforeID string, limit int) ([]model.Post, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
posts, err := s.posts.ListByUserKeyset(ctx, userID, beforeAt, beforeID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range posts {
|
||||
if posts[i].ResharedFromPostID != nil {
|
||||
posts[i].ViewerHasReshared = true
|
||||
}
|
||||
}
|
||||
if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.hydratePinnedSlice(ctx, userID, posts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
// ListHidden is the owner's "Hidden" tab — their hidden posts only, hydrated
|
||||
// like ListMine so the cards render fully and the menu offers Unhide.
|
||||
func (s *PostService) ListHidden(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||
|
||||
Reference in New Issue
Block a user