discourse-follow/app/controllers/follow/follow_controller.rb
Régis Hanol 5c0438f695
FIX: Allow users to unfollow suspended/staged/bot users (#176)
The follow button was completely hidden for suspended, staged, and bot
users. This meant that if a user followed someone who was later
suspended, they had no way to unfollow them without an admin
unsuspending and re-suspending the account.

The root cause was that `follow-button.gjs` unconditionally hid the
button based on the target user's state, with no distinction between
the follow and unfollow actions. On top of that, the `can_follow`
serializer field only checked `allow_people_to_follow_me` and not the
other states (suspended, staged, bot, self), so the client had to
duplicate those checks.

This commit introduces a `Guardian#can_follow?` method as the single
source of truth for follow permissions. The serializer delegates to it,
and the client simply shows the button when the user is already followed
OR can be followed. The `Updater` also uses the guardian as a first
check, then determines the specific error reason on failure to provide
meaningful error messages.

Other cleanups:
- Replace manual `raise if !current_user` checks with `requires_login`
- Fix mismatched i18n keys (`cannot_follow_*` → `user_cannot_follow_*`)
- Fix typo in locale ("suspend" → "suspended")

https://meta.discourse.org/t/304256
2026-03-31 11:36:41 +02:00

137 lines
3.1 KiB
Ruby

# frozen_string_literal: true
class Follow::FollowController < ApplicationController
requires_plugin Follow::PLUGIN_NAME
requires_login only: %i[follow unfollow posts]
FOLLOWING = :following
FOLLOWERS = :followers
def index
end
def follow
user = fetch_user
return if user.blank?
Follow::Updater.new(current_user, user).watch_follow
render json: success_json
end
def unfollow
user = fetch_user
return if user.blank?
Follow::Updater.new(current_user, user).unfollow
render json: success_json
end
def list_following
list(FOLLOWING)
end
def list_followers
list(FOLLOWERS)
end
def posts
user = fetch_user
return if user.blank?
ensure_can_see_feed!(user)
limit = (params[:limit].presence || 20).to_i
limit = 20 if limit <= 0
created_before = nil
if val = params[:created_before].presence
created_before = validate_date(val)
if created_before.nil?
return(
render json: {
errors: [I18n.t("follow.invalid_created_before_date", value: val.inspect)],
},
status: :bad_request
)
end
end
posts, has_more = find_posts_feed(user, limit, created_before)
render_serialized(
posts,
FollowPostSerializer,
root: "posts",
extras: {
has_more: has_more,
},
rest_serializer: true,
)
end
private
def list(type)
user = fetch_user
return if user.blank?
if type == FOLLOWERS
raise Discourse::InvalidAccess.new if !can_see_followers?(user)
users = user.followers.to_a
elsif type == FOLLOWING
raise Discourse::InvalidAccess.new if !can_see_following?(user)
users = user.following.to_a
else
raise Discourse::InvalidParameters.new
end
serializer = ActiveModel::ArraySerializer.new(users, each_serializer: BasicUserSerializer)
render json: MultiJson.dump(serializer)
end
def can_see_following?(target_user)
FollowPagesVisibility.can_see_following_page?(user: current_user, target_user: target_user)
end
def can_see_followers?(target_user)
FollowPagesVisibility.can_see_followers_page?(user: current_user, target_user: target_user)
end
def fetch_user
user = User.find_by_username(params.require(:username))
if user.blank?
render json: {
errors: [I18n.t("follow.user_not_found", username: params[:username].inspect)],
},
status: :not_found
return nil
end
user
end
def ensure_can_see_feed!(target_user)
raise Discourse::InvalidAccess.new if target_user.id != current_user.id && !current_user.staff?
end
def validate_date(value)
value.to_s.to_datetime
rescue Date::Error
nil
end
def find_posts_feed(target_user, limit, created_before)
posts =
UserFollower.posts_for(
target_user,
current_user: current_user,
limit: limit + 1,
created_before: created_before,
).to_a
has_more = false
if posts.size == limit + 1
has_more = true
posts.pop
end
[posts, has_more]
end
end