fix: hide blocked user content
This commit is contained in:
@@ -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)
|
||||
|
||||
+25
-10
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user