mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-10 07:11:00 +08:00
# Hide IP Addresses from Moderators When `moderators_view_ips` is Disabled ## Summary Feature Request Link - https://meta.discourse.org/t/option-to-hide-ip-addresses-from-moderators/207715/51 This PR implements a feature to **hide IP addresses from moderators** when the `moderators_view_ips` site setting is disabled. Previously, moderators could view IPs in multiple locations across the admin UI. This update ensures that IP addresses are visible to moderators when the setting allows it. ## Changes Implemented ### Backend Updates - **Added `moderators_view_ips` site setting** in `site_settings.yml` - **Updated `CurrentUserSerializer`** to include `can_see_ip` field based on the user’s role and site setting. - **Modified `AdminUserSerializer`** to restrict IP address visibility. - **Updated `UsersController`** to prevent IP addresses from being included in API responses. - **Restricted IPs in `ScreenedIpAddressesController`** by throwing `Discourse::InvalidAccess` if the user lacks permission. ### Frontend Updates - **Hid "Screened IPs" tab** in `/admin/logs` when `moderators_view_ips` is disabled. - **Blocked direct access to `/admin/logs/screened_ip_addresses`** for unauthorized users. - **Updated `user-index.hbs` and `logs.hbs`** to conditionally hide IP fields. ### UI Screenshots New option for Admins in the Admin Security settings dashboard:  Moderator's view before:  Moderator's view after:  Moderator's view before:  Moderator's view after:  --------- Co-authored-by: Bennett Dungan <bennettdungan@gmail.com>
581 lines
17 KiB
Ruby
581 lines
17 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Admin::UsersController < Admin::StaffController
|
|
before_action :fetch_user,
|
|
only: %i[
|
|
suspend
|
|
unsuspend
|
|
log_out
|
|
grant_admin
|
|
revoke_admin
|
|
revoke_moderation
|
|
grant_moderation
|
|
approve
|
|
activate
|
|
deactivate
|
|
silence
|
|
unsilence
|
|
trust_level
|
|
trust_level_lock
|
|
add_group
|
|
remove_group
|
|
primary_group
|
|
anonymize
|
|
merge
|
|
reset_bounce_score
|
|
disable_second_factor
|
|
delete_posts_batch
|
|
sso_record
|
|
delete_associated_accounts
|
|
]
|
|
|
|
def index
|
|
users = ::AdminUserIndexQuery.new(params).find_users
|
|
|
|
opts = { include_can_be_deleted: true, include_silence_reason: true }
|
|
if params[:show_emails] == "true"
|
|
StaffActionLogger.new(current_user).log_show_emails(users, context: request.path)
|
|
opts[:emails_desired] = true
|
|
end
|
|
|
|
render_serialized(users, AdminUserListSerializer, opts)
|
|
end
|
|
|
|
def show
|
|
@user = User.find_by(id: params[:id])
|
|
raise Discourse::NotFound unless @user
|
|
|
|
render_serialized(
|
|
@user,
|
|
AdminDetailedUserSerializer,
|
|
root: false,
|
|
similar_users_count: @user.similar_users.count,
|
|
include_silence_reason: true,
|
|
include_ip: guardian.can_see_ip?,
|
|
)
|
|
end
|
|
|
|
def similar_users
|
|
@user = User.find_by(id: params[:user_id])
|
|
raise Discourse::NotFound if !@user
|
|
|
|
render_json_dump(
|
|
{
|
|
users:
|
|
ActiveModel::ArraySerializer.new(
|
|
@user.similar_users.limit(User::MAX_SIMILAR_USERS),
|
|
each_serializer: SimilarAdminUserSerializer,
|
|
scope: guardian,
|
|
root: false,
|
|
),
|
|
},
|
|
)
|
|
end
|
|
|
|
def delete_posts_batch
|
|
deleted_posts = @user.delete_posts_in_batches(guardian)
|
|
# staff action logs will have an entry for each post
|
|
|
|
render json: { posts_deleted: deleted_posts.length }
|
|
end
|
|
|
|
# DELETE action to delete penalty history for a user
|
|
def penalty_history
|
|
# We don't delete any history, we merely remove the action type
|
|
# with a removed type. It can still be viewed in the logs but
|
|
# will not affect TL3 promotions.
|
|
sql = <<~SQL
|
|
UPDATE user_histories
|
|
SET action = CASE
|
|
WHEN action = :silence_user THEN :removed_silence_user
|
|
WHEN action = :unsilence_user THEN :removed_unsilence_user
|
|
WHEN action = :suspend_user THEN :removed_suspend_user
|
|
WHEN action = :unsuspend_user THEN :removed_unsuspend_user
|
|
END
|
|
WHERE target_user_id = :user_id
|
|
AND action IN (
|
|
:silence_user,
|
|
:suspend_user,
|
|
:unsilence_user,
|
|
:unsuspend_user
|
|
)
|
|
SQL
|
|
|
|
DB.exec(
|
|
sql,
|
|
UserHistory
|
|
.actions
|
|
.slice(
|
|
:silence_user,
|
|
:suspend_user,
|
|
:unsilence_user,
|
|
:unsuspend_user,
|
|
:removed_silence_user,
|
|
:removed_unsilence_user,
|
|
:removed_suspend_user,
|
|
:removed_unsuspend_user,
|
|
)
|
|
.merge(user_id: params[:user_id].to_i),
|
|
)
|
|
|
|
render json: success_json
|
|
end
|
|
|
|
def suspend
|
|
User::Suspend.call(service_params) do
|
|
on_success do |params:, user:, full_reason:|
|
|
render_json_dump(
|
|
suspension: {
|
|
suspend_reason: params.reason,
|
|
full_suspend_reason: full_reason,
|
|
suspended_till: user.suspended_till,
|
|
suspended_at: user.suspended_at,
|
|
suspended_by: BasicUserSerializer.new(current_user, root: false).as_json,
|
|
},
|
|
)
|
|
end
|
|
on_failed_contract do |contract|
|
|
render json: failed_json.merge(errors: contract.errors.full_messages), status: 400
|
|
end
|
|
on_model_not_found(:user) { raise Discourse::NotFound }
|
|
on_failed_policy(:not_suspended_already) do |policy|
|
|
render json: failed_json.merge(message: policy.reason), status: 409
|
|
end
|
|
on_failed_policy(:can_suspend_all_users) { raise Discourse::InvalidAccess.new }
|
|
end
|
|
end
|
|
|
|
def unsuspend
|
|
guardian.ensure_can_suspend!(@user)
|
|
@user.suspended_till = nil
|
|
@user.suspended_at = nil
|
|
@user.save!
|
|
StaffActionLogger.new(current_user).log_user_unsuspend(@user)
|
|
|
|
DiscourseEvent.trigger(:user_unsuspended, user: @user)
|
|
|
|
render_json_dump(suspension: { suspended_till: nil, suspended_at: nil })
|
|
end
|
|
|
|
def log_out
|
|
if @user
|
|
@user.user_auth_tokens.destroy_all
|
|
@user.logged_out
|
|
render json: success_json
|
|
else
|
|
render json: { error: I18n.t("admin_js.admin.users.id_not_found") }, status: 404
|
|
end
|
|
end
|
|
|
|
def revoke_admin
|
|
guardian.ensure_can_revoke_admin!(@user)
|
|
@user.revoke_admin!
|
|
StaffActionLogger.new(current_user).log_revoke_admin(@user)
|
|
render_serialized(@user, AdminDetailedUserSerializer, root: false)
|
|
end
|
|
|
|
def grant_admin
|
|
result = run_second_factor!(SecondFactor::Actions::GrantAdmin)
|
|
if result.no_second_factors_enabled?
|
|
render json: success_json.merge(email_confirmation_required: true)
|
|
else
|
|
render json: success_json
|
|
end
|
|
end
|
|
|
|
def revoke_moderation
|
|
guardian.ensure_can_revoke_moderation!(@user)
|
|
@user.revoke_moderation!
|
|
StaffActionLogger.new(current_user).log_revoke_moderation(@user)
|
|
render_serialized(@user, AdminDetailedUserSerializer, root: false)
|
|
end
|
|
|
|
def grant_moderation
|
|
guardian.ensure_can_grant_moderation!(@user)
|
|
@user.grant_moderation!
|
|
StaffActionLogger.new(current_user).log_grant_moderation(@user)
|
|
render_serialized(@user, AdminDetailedUserSerializer, root: false)
|
|
end
|
|
|
|
def add_group
|
|
group = Group.find(params[:group_id].to_i)
|
|
raise Discourse::NotFound unless group
|
|
|
|
return render_json_error(I18n.t("groups.errors.can_not_modify_automatic")) if group.automatic
|
|
guardian.ensure_can_edit!(group)
|
|
|
|
group.add(@user)
|
|
GroupActionLogger.new(current_user, group).log_add_user_to_group(@user)
|
|
|
|
render body: nil
|
|
end
|
|
|
|
def remove_group
|
|
group = Group.find(params[:group_id].to_i)
|
|
raise Discourse::NotFound unless group
|
|
|
|
return render_json_error(I18n.t("groups.errors.can_not_modify_automatic")) if group.automatic
|
|
guardian.ensure_can_edit!(group)
|
|
|
|
if group.remove(@user)
|
|
GroupActionLogger.new(current_user, group).log_remove_user_from_group(@user)
|
|
end
|
|
|
|
render body: nil
|
|
end
|
|
|
|
def primary_group
|
|
if params[:primary_group_id].present?
|
|
primary_group_id = params[:primary_group_id].to_i
|
|
if group = Group.find(primary_group_id)
|
|
guardian.ensure_can_change_primary_group!(@user, group)
|
|
|
|
@user.primary_group_id = primary_group_id if group.user_ids.include?(@user.id)
|
|
end
|
|
else
|
|
@user.primary_group_id = nil
|
|
end
|
|
|
|
@user.save!
|
|
|
|
render body: nil
|
|
end
|
|
|
|
def trust_level
|
|
guardian.ensure_can_change_trust_level!(@user)
|
|
level = params[:level].to_i
|
|
|
|
if @user.manual_locked_trust_level.nil?
|
|
if [0, 1, 2].include?(level) && Promotion.public_send("tl#{level + 1}_met?", @user)
|
|
@user.manual_locked_trust_level = level
|
|
@user.save
|
|
elsif level == 3 && Promotion.tl3_lost?(@user)
|
|
@user.manual_locked_trust_level = level
|
|
@user.save
|
|
end
|
|
end
|
|
|
|
@user.change_trust_level!(level, log_action_for: current_user)
|
|
|
|
render_serialized(@user, AdminUserSerializer)
|
|
rescue Discourse::InvalidAccess => e
|
|
render_json_error(e.message)
|
|
end
|
|
|
|
def trust_level_lock
|
|
guardian.ensure_can_change_trust_level!(@user)
|
|
|
|
new_lock = params[:locked].to_s
|
|
return render_json_error I18n.t("errors.invalid_boolean") unless new_lock =~ /true|false/
|
|
|
|
@user.manual_locked_trust_level = (new_lock == "true") ? @user.trust_level : nil
|
|
@user.save
|
|
|
|
StaffActionLogger.new(current_user).log_lock_trust_level(@user)
|
|
Promotion.recalculate(@user, current_user)
|
|
|
|
render body: nil
|
|
end
|
|
|
|
def approve
|
|
guardian.ensure_can_approve!(@user)
|
|
|
|
reviewable =
|
|
ReviewableUser.find_by(target: @user) ||
|
|
Jobs::CreateUserReviewable.new.execute(user_id: @user.id).reviewable
|
|
|
|
reviewable.perform(current_user, :approve_user)
|
|
render body: nil
|
|
end
|
|
|
|
def approve_bulk
|
|
Reviewable.bulk_perform_targets(current_user, :approve_user, "ReviewableUser", params[:users])
|
|
render body: nil
|
|
end
|
|
|
|
def activate
|
|
guardian.ensure_can_activate!(@user)
|
|
# ensure there is an active email token
|
|
if !@user.email_tokens.active.exists?
|
|
@user.email_tokens.create!(email: @user.email, scope: EmailToken.scopes[:signup])
|
|
end
|
|
@user.activate
|
|
StaffActionLogger.new(current_user).log_user_activate(@user, I18n.t("user.activated_by_staff"))
|
|
render json: success_json
|
|
end
|
|
|
|
def deactivate
|
|
guardian.ensure_can_deactivate!(@user)
|
|
@user.deactivate(current_user)
|
|
StaffActionLogger.new(current_user).log_user_deactivate(
|
|
@user,
|
|
I18n.t("user.deactivated_by_staff"),
|
|
params.slice(:context),
|
|
)
|
|
refresh_browser @user
|
|
render json: success_json
|
|
end
|
|
|
|
def silence
|
|
User::Silence.call(service_params) do
|
|
on_success do |full_reason:, user:|
|
|
render_json_dump(
|
|
silence: {
|
|
silenced: true,
|
|
silence_reason: full_reason,
|
|
silenced_till: user.silenced_till,
|
|
silenced_at: user.silenced_at,
|
|
silenced_by:
|
|
BasicUserSerializer.new(
|
|
current_user,
|
|
root: false,
|
|
include_silence_reason: true,
|
|
).as_json,
|
|
},
|
|
)
|
|
end
|
|
on_failed_contract do |contract|
|
|
render json: failed_json.merge(errors: contract.errors.full_messages), status: 400
|
|
end
|
|
on_model_not_found(:user) { raise Discourse::NotFound }
|
|
on_failed_policy(:not_silenced_already) do |policy|
|
|
render json: failed_json.merge(message: policy.reason), status: 409
|
|
end
|
|
on_failed_policy(:can_silence_all_users) { raise Discourse::InvalidAccess.new }
|
|
end
|
|
end
|
|
|
|
def unsilence
|
|
guardian.ensure_can_unsilence_user! @user
|
|
UserSilencer.unsilence(@user, current_user)
|
|
|
|
render_json_dump(
|
|
unsilence: {
|
|
silenced: false,
|
|
silence_reason: nil,
|
|
silenced_till: nil,
|
|
silenced_at: nil,
|
|
},
|
|
)
|
|
end
|
|
|
|
def disable_second_factor
|
|
guardian.ensure_can_disable_second_factor!(@user)
|
|
user_second_factor = @user.user_second_factors
|
|
user_security_key = @user.security_keys
|
|
raise Discourse::InvalidParameters if user_second_factor.empty? && user_security_key.empty?
|
|
|
|
user_second_factor.destroy_all
|
|
user_security_key.destroy_all
|
|
StaffActionLogger.new(current_user).log_disable_second_factor_auth(@user)
|
|
|
|
Jobs.enqueue(:critical_user_email, type: "account_second_factor_disabled", user_id: @user.id)
|
|
|
|
render json: success_json
|
|
end
|
|
|
|
def destroy
|
|
user = User.find_by(id: params[:id].to_i)
|
|
guardian.ensure_can_delete_user!(user)
|
|
|
|
options = params.slice(:context, :delete_as_spammer)
|
|
%i[delete_posts block_email block_urls block_ip].each do |param_name|
|
|
options[param_name] = ActiveModel::Type::Boolean.new.cast(params[param_name])
|
|
end
|
|
options[:prepare_for_destroy] = true
|
|
|
|
hijack do
|
|
begin
|
|
if UserDestroyer.new(current_user).destroy(user, options)
|
|
render json: { deleted: true }
|
|
else
|
|
render json: {
|
|
deleted: false,
|
|
user: AdminDetailedUserSerializer.new(user, root: false).as_json,
|
|
}
|
|
end
|
|
rescue UserDestroyer::PostsExistError
|
|
render json: {
|
|
deleted: false,
|
|
message:
|
|
I18n.t(
|
|
"user.cannot_delete_has_posts",
|
|
username: user.username,
|
|
count: user.posts.joins(:topic).count,
|
|
),
|
|
},
|
|
status: 403
|
|
end
|
|
end
|
|
end
|
|
|
|
def destroy_bulk
|
|
# capture service_params outside the hijack block to avoid thread safety
|
|
# issues
|
|
service_arg = service_params
|
|
|
|
hijack do
|
|
User::BulkDestroy.call(service_arg) do
|
|
on_success { render json: { deleted: true } }
|
|
|
|
on_failed_contract do |contract|
|
|
render json: failed_json.merge(errors: contract.errors.full_messages), status: 400
|
|
end
|
|
|
|
on_failed_policy(:can_delete_users) do
|
|
render json: failed_json.merge(errors: [I18n.t("user.cannot_bulk_delete")]), status: 403
|
|
end
|
|
|
|
on_model_not_found(:users) { render json: failed_json, status: 404 }
|
|
end
|
|
end
|
|
end
|
|
|
|
def badges
|
|
end
|
|
|
|
def tl3_requirements
|
|
end
|
|
|
|
def ip_info
|
|
params.require(:ip)
|
|
raise Discourse::InvalidAccess.new unless guardian.can_see_ip?
|
|
|
|
render json: DiscourseIpInfo.get(params[:ip], resolve_hostname: true)
|
|
end
|
|
|
|
def sync_sso
|
|
return render body: nil, status: 404 unless SiteSetting.enable_discourse_connect
|
|
|
|
begin
|
|
sso =
|
|
DiscourseConnect.parse(
|
|
"sso=#{params[:sso]}&sig=#{params[:sig]}",
|
|
secure_session: secure_session,
|
|
)
|
|
rescue DiscourseConnect::ParseError
|
|
return(
|
|
render json: failed_json.merge(message: I18n.t("discourse_connect.login_error")),
|
|
status: 422
|
|
)
|
|
end
|
|
|
|
begin
|
|
user = sso.lookup_or_create_user
|
|
DiscourseEvent.trigger(:sync_sso, user)
|
|
render_serialized(user, AdminDetailedUserSerializer, root: false)
|
|
rescue ActiveRecord::RecordInvalid => ex
|
|
render json: failed_json.merge(message: ex.message), status: 403
|
|
rescue DiscourseConnect::BlankExternalId => ex
|
|
render json: failed_json.merge(message: I18n.t("discourse_connect.blank_id_error")),
|
|
status: 422
|
|
end
|
|
end
|
|
|
|
def delete_other_accounts_with_same_ip
|
|
params.require(:ip)
|
|
params.require(:exclude)
|
|
params.require(:order)
|
|
|
|
user_destroyer = UserDestroyer.new(current_user)
|
|
options = {
|
|
delete_posts: true,
|
|
block_email: true,
|
|
block_urls: true,
|
|
block_ip: true,
|
|
delete_as_spammer: true,
|
|
context: I18n.t("user.destroy_reasons.same_ip_address", ip_address: params[:ip]),
|
|
}
|
|
|
|
AdminUserIndexQuery
|
|
.new(params)
|
|
.find_users(50)
|
|
.each { |user| user_destroyer.destroy(user, options) }
|
|
|
|
render json: success_json
|
|
end
|
|
|
|
def total_other_accounts_with_same_ip
|
|
params.require(:ip)
|
|
params.require(:exclude)
|
|
params.require(:order)
|
|
|
|
render json: { total: AdminUserIndexQuery.new(params).count_users }
|
|
end
|
|
|
|
def anonymize
|
|
guardian.ensure_can_anonymize_user!(@user)
|
|
opts = {}
|
|
opts[:anonymize_ip] = params[:anonymize_ip] if params[:anonymize_ip].present?
|
|
|
|
if user = UserAnonymizer.new(@user, current_user, opts).make_anonymous
|
|
render json: success_json.merge(username: user.username)
|
|
else
|
|
render json:
|
|
failed_json.merge(user: AdminDetailedUserSerializer.new(user, root: false).as_json)
|
|
end
|
|
end
|
|
|
|
def merge
|
|
target_username = params.require(:target_username)
|
|
target_user = User.find_by_username(target_username)
|
|
raise Discourse::NotFound if target_user.blank?
|
|
|
|
guardian.ensure_can_merge_users!(@user, target_user)
|
|
|
|
Jobs.enqueue(
|
|
:merge_user,
|
|
user_id: @user.id,
|
|
target_user_id: target_user.id,
|
|
current_user_id: current_user.id,
|
|
)
|
|
render json: success_json
|
|
end
|
|
|
|
def reset_bounce_score
|
|
guardian.ensure_can_reset_bounce_score!(@user)
|
|
@user.user_stat&.reset_bounce_score!
|
|
StaffActionLogger.new(current_user).log_reset_bounce_score(@user)
|
|
render json: success_json
|
|
end
|
|
|
|
def sso_record
|
|
guardian.ensure_can_delete_sso_record!(@user)
|
|
@user.single_sign_on_record.destroy!
|
|
render json: success_json
|
|
end
|
|
|
|
def delete_associated_accounts
|
|
guardian.ensure_can_delete_user_associated_accounts!(@user)
|
|
previous_value =
|
|
@user
|
|
.user_associated_accounts
|
|
.select(:provider_name, :provider_uid, :info)
|
|
.map do |associated_account|
|
|
{
|
|
provider: associated_account.provider_name,
|
|
uid: associated_account.provider_uid,
|
|
info: associated_account.info,
|
|
}.to_s
|
|
end
|
|
.join(",")
|
|
StaffActionLogger.new(current_user).log_delete_associated_accounts(
|
|
@user,
|
|
previous_value:,
|
|
context: params[:context],
|
|
)
|
|
@user.user_associated_accounts.delete_all
|
|
render json: success_json
|
|
end
|
|
|
|
private
|
|
|
|
def fetch_user
|
|
@user = User.find_by(id: params[:user_id])
|
|
raise Discourse::NotFound unless @user
|
|
end
|
|
|
|
def refresh_browser(user)
|
|
MessageBus.publish "/file-change", ["refresh"], user_ids: [user.id]
|
|
end
|
|
end
|