diff --git a/internal/handler/social.go b/internal/handler/social.go index 9def979..c50ca93 100644 --- a/internal/handler/social.go +++ b/internal/handler/social.go @@ -593,6 +593,32 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) { }) } +// gateByBlock resolves the profile owner by handle and returns ErrBlocked +// (→403) when a block exists in either direction between the signed-in +// viewer and the owner — so a blocked viewer can't read the owner's feed, +// tagged posts, or follower lists, even by hitting the API directly. It +// returns the resolved owner so callers needn't look it up again, and +// propagates ErrUserNotFound (→404) for an unknown handle. Anonymous +// viewers and owners viewing their own profile are never gated. +func (h *ProfileHandler) gateByBlock(r *http.Request, handle string) (*model.User, error) { + owner, err := h.users.FindByHandle(r.Context(), handle) + if err != nil { + return nil, err + } + viewerID := middleware.UserID(r.Context()) + if viewerID == "" || viewerID == owner.ID { + return owner, nil + } + blocked, err := h.graph.IsBlockedBetween(r.Context(), viewerID, owner.ID) + if err != nil { + return nil, err + } + if blocked { + return nil, service.ErrBlocked + } + return owner, nil +} + // Feed returns posts for the public feed. Same payload as ListMine but // looked up by handle so it's reachable anonymously. When an // authenticated viewer hits this route (via OptionalAuth) each post @@ -600,8 +626,15 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) { // button. Anonymous viewers get `false` for every post at zero cost. func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) { handle := chi.URLParam(r, "handle") + owner, err := h.gateByBlock(r, handle) + if err != nil { + mapSocialErr(w, err) + return + } limit, offset := parseLimitOffset(r) - posts, total, err := h.posts.ListByHandle(r.Context(), handle, limit, offset) + // gateByBlock already resolved the owner, so list by id rather than + // re-resolving the handle. + posts, total, err := h.posts.ListByUser(r.Context(), owner.ID, limit, offset) if err != nil { mapSocialErr(w, err) return @@ -617,6 +650,10 @@ func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) { // user, newest first. Public (OptionalAuth) with viewer-state hydration. func (h *ProfileHandler) MentionsFeed(w http.ResponseWriter, r *http.Request) { handle := chi.URLParam(r, "handle") + if _, err := h.gateByBlock(r, handle); err != nil { + mapSocialErr(w, err) + return + } limit, offset := parseLimitOffset(r) posts, total, err := h.posts.ListMentioningByHandle(r.Context(), handle, limit, offset) if err != nil { @@ -679,6 +716,10 @@ type followListResp struct { // name/handle search. Public (OptionalAuth). func (h *ProfileHandler) Followers(w http.ResponseWriter, r *http.Request) { handle := chi.URLParam(r, "handle") + if _, err := h.gateByBlock(r, handle); err != nil { + mapSocialErr(w, err) + return + } limit, offset := parseLimitOffset(r) q := strings.TrimSpace(r.URL.Query().Get("q")) users, total, err := h.graph.Followers(r.Context(), handle, q, limit, offset) @@ -692,6 +733,10 @@ func (h *ProfileHandler) Followers(w http.ResponseWriter, r *http.Request) { // Following — GET /u/{handle}/following?limit=&offset=&q=. func (h *ProfileHandler) Following(w http.ResponseWriter, r *http.Request) { handle := chi.URLParam(r, "handle") + if _, err := h.gateByBlock(r, handle); err != nil { + mapSocialErr(w, err) + return + } limit, offset := parseLimitOffset(r) q := strings.TrimSpace(r.URL.Query().Get("q")) users, total, err := h.graph.Following(r.Context(), handle, q, limit, offset) diff --git a/internal/handler/user.go b/internal/handler/user.go index a98382f..dd1c8b9 100644 --- a/internal/handler/user.go +++ b/internal/handler/user.go @@ -94,21 +94,31 @@ type userSearchItem struct { // the query carries an "@" prefix. func (h *UserHandler) Search(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) - if q == "" { - response.JSON(w, http.StatusOK, map[string]any{ - "query": "", - "results": []userSearchItem{}, - }) - return - } limit := 20 if v := r.URL.Query().Get("limit"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 { limit = n } } + if q == "" { + response.JSON(w, http.StatusOK, map[string]any{ + "query": "", + "results": []userSearchItem{}, + "total": 0, + "page": 1, + "limit": limit, + "total_pages": 0, + }) + return + } + page := 1 + if v := r.URL.Query().Get("page"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 1 { + page = n + } + } - users, err := h.svc.Search(r.Context(), q, limit) + users, total, err := h.svc.Search(r.Context(), q, limit, (page-1)*limit) if err != nil { response.Error(w, http.StatusInternalServerError, "search failed") return @@ -122,9 +132,14 @@ func (h *UserHandler) Search(w http.ResponseWriter, r *http.Request) { AvatarURL: u.AvatarURL, }) } + totalPages := (total + limit - 1) / limit response.JSON(w, http.StatusOK, map[string]any{ - "query": q, - "results": items, + "query": q, + "results": items, + "total": total, + "page": page, + "limit": limit, + "total_pages": totalPages, }) } diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index 1a20e09..4f3bde5 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -321,17 +321,35 @@ func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error // Postgres ILIKE is fine at our user-table scale (low six figures // max). When the table grows enough to warrant fuzzy / trigram // matching, add a pg_trgm GIN index and switch to similarity(). -func (r *UserRepo) Search(ctx context.Context, q string, limit int) ([]model.User, error) { +func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]model.User, int, error) { cleaned := strings.TrimPrefix(strings.TrimSpace(q), "@") if cleaned == "" { - return []model.User{}, nil + return []model.User{}, 0, nil } if limit <= 0 || limit > 50 { limit = 20 } + if offset < 0 { + offset = 0 + } substring := "%" + cleaned + "%" prefix := cleaned + "%" + // Total matches for pagination — same WHERE as the page query. Cheap + // enough at our user-table scale; revisit alongside the pg_trgm switch. + var total int + if err := r.pg.Pool.QueryRow(ctx, + `SELECT COUNT(*) FROM users WHERE name ILIKE $1 OR handle::text ILIKE $1`, + substring).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count search users: %w", err) + } + if total == 0 || offset >= total { + return []model.User{}, total, nil + } + + // The trailing `handle` tiebreaker makes the ordering total (handle is + // unique) so OFFSET paging is stable — without it, rows sharing a rank + // and name could reshuffle between pages. sql := `SELECT ` + userColumns + ` FROM users WHERE name ILIKE $1 OR handle::text ILIKE $1 @@ -341,23 +359,24 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit int) ([]model.Use WHEN name ILIKE $2 THEN 1 ELSE 2 END, - name - LIMIT $3` + name, + handle + LIMIT $3 OFFSET $4` - rows, err := r.pg.Pool.Query(ctx, sql, substring, prefix, limit) + rows, err := r.pg.Pool.Query(ctx, sql, substring, prefix, limit, offset) if err != nil { - return nil, fmt.Errorf("search users: %w", err) + return nil, 0, fmt.Errorf("search users: %w", err) } defer rows.Close() out := []model.User{} for rows.Next() { var u model.User if err := scanUser(rows, &u); err != nil { - return nil, fmt.Errorf("scan user: %w", err) + return nil, 0, fmt.Errorf("scan user: %w", err) } out = append(out, u) } - return out, rows.Err() + return out, total, rows.Err() } // GetReadingPrefs returns the raw JSONB bytes for the user's reading diff --git a/internal/service/social_graph_service.go b/internal/service/social_graph_service.go index 1152720..c0505ee 100644 --- a/internal/service/social_graph_service.go +++ b/internal/service/social_graph_service.go @@ -296,6 +296,23 @@ func (s *SocialGraphService) Relationship(ctx context.Context, viewerID, targetI return out, nil } +// IsBlockedBetween reports whether a block exists in EITHER direction +// between two users — viewer→target or target→viewer. Content visibility +// is gated on this (a block either way hides the target's profile feed, +// tagged posts, and follower lists from the viewer), mirroring the +// "viewerBlocked || targetBlocked" rule RequestFollow enforces. Returns +// false for anonymous viewers (empty id) and for the self case. +func (s *SocialGraphService) IsBlockedBetween(ctx context.Context, a, b string) (bool, error) { + if a == "" || b == "" || a == b { + return false, nil + } + aBlockedB, bBlockedA, err := s.blocks.BlockState(ctx, a, b) + if err != nil { + return false, err + } + return aBlockedB || bBlockedA, nil +} + // Counts returns the accepted-follower and accepted-following totals for // a user. func (s *SocialGraphService) Counts(ctx context.Context, userID string) (followers, following int, err error) { diff --git a/internal/service/user_service.go b/internal/service/user_service.go index 323ef3d..eacb01e 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -158,10 +158,11 @@ func normalizeClock(raw string) (string, error) { return t, nil } -// Search returns users matching the query (handle or name substring). +// Search returns the `offset`/`limit` page of users matching the query +// (handle or name substring) plus the total match count for pagination. // Used by the public user-search endpoint on the /search page. -func (s *UserService) Search(ctx context.Context, q string, limit int) ([]model.User, error) { - return s.users.Search(ctx, q, limit) +func (s *UserService) Search(ctx context.Context, q string, limit, offset int) ([]model.User, int, error) { + return s.users.Search(ctx, q, limit, offset) } // GetReadingPrefs returns the user's reading prefs blob (opaque JSON).