discourse/app/controllers/admin/users_controller.rb

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

570 lines
16 KiB
Ruby
Raw Normal View History

# frozen_string_literal: true
class Admin::UsersController < Admin::StaffController
before_action :fetch_user,
only: %i[
suspend
unsuspend
2014-06-06 11:02:52 +08:00
log_out
grant_admin
2013-10-23 03:53:08 +08:00
revoke_admin
revoke_moderation
grant_moderation
approve
activate
deactivate
2017-11-11 01:18:08 +08:00
silence
unsilence
2013-10-23 03:53:08 +08:00
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
]
2013-02-06 03:16:51 +08:00
def index
users = ::AdminUserIndexQuery.new(params).find_users
opts = { include_can_be_deleted: 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)
2013-02-06 03:16:51 +08:00
end
def show
@user = User.find_by(id: params[:id])
2015-05-07 09:00:51 +08:00
raise Discourse::NotFound unless @user
render_serialized(
@user,
AdminDetailedUserSerializer,
root: false,
similar_users_count: @user.similar_users.count,
)
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,
),
},
)
2013-02-06 03:16:51 +08:00
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
2013-02-06 03:16:51 +08:00
end
def unsuspend
guardian.ensure_can_suspend!(@user)
@user.suspended_till = nil
@user.suspended_at = nil
2013-02-06 03:16:51 +08:00
@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 })
2013-02-06 03:16:51 +08:00
end
2014-06-06 11:02:52 +08:00
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
2014-06-06 11:02:52 +08:00
end
2013-02-06 03:16:51 +08:00
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)
2013-02-06 03:16:51 +08:00
end
def grant_admin
FEATURE: Centralized 2FA page (#15377) 2FA support in Discourse was added and grown gradually over the years: we first added support for TOTP for logins, then we implemented backup codes, and last but not least, security keys. 2FA usage was initially limited to logging in, but it has been expanded and we now require 2FA for risky actions such as adding a new admin to the site. As a result of this gradual growth of the 2FA system, technical debt has accumulated to the point where it has become difficult to require 2FA for more actions. We now have 5 different 2FA UI implementations and each one has to support all 3 2FA methods (TOTP, backup codes, and security keys) which makes it difficult to maintain a consistent UX for these different implementations. Moreover, there is a lot of repeated logic in the server-side code behind these 5 UI implementations which hinders maintainability even more. This commit is the first step towards repaying the technical debt: it builds a system that centralizes as much as possible of the 2FA server-side logic and UI. The 2 main components of this system are: 1. A dedicated page for 2FA with support for all 3 methods. 2. A reusable server-side class that centralizes the 2FA logic (the `SecondFactor::AuthManager` class). From a top-level view, the 2FA flow in this new system looks like this: 1. User initiates an action that requires 2FA; 2. Server is aware that 2FA is required for this action, so it redirects the user to the 2FA page if the user has a 2FA method, otherwise the action is performed. 3. User submits the 2FA form on the page; 4. Server validates the 2FA and if it's successful, the action is performed and the user is redirected to the previous page. A more technically-detailed explanation/documentation of the new system is available as a comment at the top of the `lib/second_factor/auth_manager.rb` file. Please note that the details are not set in stone and will likely change in the future, so please don't use the system in your plugins yet. Since this is a new system that needs to be tested, we've decided to migrate only the 2FA for adding a new admin to the new system at this time (in this commit). Our plan is to gradually migrate the remaining 2FA implementations to the new system. For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 17:12:59 +08:00
result = run_second_factor!(SecondFactor::Actions::GrantAdmin)
if result.no_second_factors_enabled?
render json: success_json.merge(email_confirmation_required: true)
FEATURE: Centralized 2FA page (#15377) 2FA support in Discourse was added and grown gradually over the years: we first added support for TOTP for logins, then we implemented backup codes, and last but not least, security keys. 2FA usage was initially limited to logging in, but it has been expanded and we now require 2FA for risky actions such as adding a new admin to the site. As a result of this gradual growth of the 2FA system, technical debt has accumulated to the point where it has become difficult to require 2FA for more actions. We now have 5 different 2FA UI implementations and each one has to support all 3 2FA methods (TOTP, backup codes, and security keys) which makes it difficult to maintain a consistent UX for these different implementations. Moreover, there is a lot of repeated logic in the server-side code behind these 5 UI implementations which hinders maintainability even more. This commit is the first step towards repaying the technical debt: it builds a system that centralizes as much as possible of the 2FA server-side logic and UI. The 2 main components of this system are: 1. A dedicated page for 2FA with support for all 3 methods. 2. A reusable server-side class that centralizes the 2FA logic (the `SecondFactor::AuthManager` class). From a top-level view, the 2FA flow in this new system looks like this: 1. User initiates an action that requires 2FA; 2. Server is aware that 2FA is required for this action, so it redirects the user to the 2FA page if the user has a 2FA method, otherwise the action is performed. 3. User submits the 2FA form on the page; 4. Server validates the 2FA and if it's successful, the action is performed and the user is redirected to the previous page. A more technically-detailed explanation/documentation of the new system is available as a comment at the top of the `lib/second_factor/auth_manager.rb` file. Please note that the details are not set in stone and will likely change in the future, so please don't use the system in your plugins yet. Since this is a new system that needs to be tested, we've decided to migrate only the 2FA for adding a new admin to the new system at this time (in this commit). Our plan is to gradually migrate the remaining 2FA implementations to the new system. For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 17:12:59 +08:00
else
render json: success_json
end
2013-02-06 03:16:51 +08:00
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)
2013-05-06 12:49:56 +08:00
@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)
2016-12-11 23:36:15 +08:00
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?
2019-05-07 09:27:05 +08:00
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
2015-05-26 21:16:55 +08:00
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
2013-02-06 03:16:51 +08:00
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
2013-02-06 03:16:51 +08:00
end
def approve_bulk
Reviewable.bulk_perform_targets(current_user, :approve_user, "ReviewableUser", params[:users])
render body: nil
2013-02-06 03:16:51 +08:00
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
2017-11-11 01:18:08 +08:00
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).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
2017-11-11 01:18:08 +08:00
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)
2018-06-28 16:12:32 +08:00
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?
2018-06-28 16:12:32 +08:00
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,
Fix i18n issues reported on Crowdin (#13191) * Pluralize `js.topics.bulk.dismiss_new_with_selected` This fixes https://discourse.crowdin.com/translate/f3230e7607a36bb0a2f97fd90605a44e/246/en-fr#57320 * Pluralize `js.topics.bulk.dismiss_read_with_selected` This fixes https://discourse.crowdin.com/translate/f3230e7607a36bb0a2f97fd90605a44e/246/en-fr#57316 * Pluralize `js.topics.bulk.dismiss_button_with_selected` * Replaces concatenated string used by `js.topic.suggest_create_topic` This fixes https://discourse.crowdin.com/translate/f3230e7607a36bb0a2f97fd90605a44e/246/en-fr#41834 * Less confusing `admin_js.admin.watched_words.test.modal_title` This fixes https://discourse.crowdin.com/translate/f3230e7607a36bb0a2f97fd90605a44e/246/en-sv#44992 * Delete unused `backup.location.*` keys This fixes https://discourse.crowdin.com/translate/f3230e7607a36bb0a2f97fd90605a44e/248/en-fr#46330 * Replace "reviewable" with "reviewable items" This fixes https://discourse.crowdin.com/translate/f3230e7607a36bb0a2f97fd90605a44e/248/en-fr#56952 * Remove "ago" from `emails.incoming.missing_attachment` This fixes https://discourse.crowdin.com/translate/f3230e7607a36bb0a2f97fd90605a44e/248/en-sv#46038 * Remove "/Posts" from `js.keyboard_shortcuts_help.application.dismiss_new_posts` Because the shortcut doesn't do anything to posts anymore. This fixes https://discourse.crowdin.com/translate/f3230e7607a36bb0a2f97fd90605a44e/246/en-de#43180 * Pluralize `user.cannot_delete_has_posts` This fixes https://discourse.crowdin.com/translate/f3230e7607a36bb0a2f97fd90605a44e/248/en-he#57490
2021-06-22 17:29:35 +08:00
message:
I18n.t(
"user.cannot_delete_has_posts",
username: user.username,
count: user.posts.joins(:topic).count,
),
},
status: 403
end
end
end
2013-02-06 03:16:51 +08:00
def destroy_bulk
hijack do
User::BulkDestroy.call(service_params) 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
2014-09-25 08:19:26 +08:00
def tl3_requirements
end
def ip_info
params.require(:ip)
render json: DiscourseIpInfo.get(params[:ip], resolve_hostname: true)
end
def sync_sso
FEATURE: Rename 'Discourse SSO' to DiscourseConnect (#11978) The 'Discourse SSO' protocol is being rebranded to DiscourseConnect. This should help to reduce confusion when 'SSO' is used in the generic sense. This commit aims to: - Rename `sso_` site settings. DiscourseConnect specific ones are prefixed `discourse_connect_`. Generic settings are prefixed `auth_` - Add (server-side-only) backwards compatibility for the old setting names, with deprecation notices - Copy `site_settings` database records to the new names - Rename relevant translation keys - Update relevant translations This commit does **not** aim to: - Rename any Ruby classes or methods. This might be done in a future commit - Change any URLs. This would break existing integrations - Make any changes to the protocol. This would break existing integrations - Change any functionality. Further normalization across DiscourseConnect and other auth methods will be done separately The risks are: - There is no backwards compatibility for site settings on the client-side. Accessing auth-related site settings in Javascript is fairly rare, and an error on the client side would not be security-critical. - If a plugin is monkey-patching parts of the auth process, changes to locale keys could cause broken error messages. This should also be unlikely. The old site setting names remain functional, so security-related overrides will remain working. A follow-up commit will be made with a post-deploy migration to delete the old `site_settings` rows.
2021-02-08 18:04:33 +08:00
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
FEATURE: Rename 'Discourse SSO' to DiscourseConnect (#11978) The 'Discourse SSO' protocol is being rebranded to DiscourseConnect. This should help to reduce confusion when 'SSO' is used in the generic sense. This commit aims to: - Rename `sso_` site settings. DiscourseConnect specific ones are prefixed `discourse_connect_`. Generic settings are prefixed `auth_` - Add (server-side-only) backwards compatibility for the old setting names, with deprecation notices - Copy `site_settings` database records to the new names - Rename relevant translation keys - Update relevant translations This commit does **not** aim to: - Rename any Ruby classes or methods. This might be done in a future commit - Change any URLs. This would break existing integrations - Make any changes to the protocol. This would break existing integrations - Change any functionality. Further normalization across DiscourseConnect and other auth methods will be done separately The risks are: - There is no backwards compatibility for site settings on the client-side. Accessing auth-related site settings in Javascript is fairly rare, and an error on the client side would not be security-critical. - If a plugin is monkey-patching parts of the auth process, changes to locale keys could cause broken error messages. This should also be unlikely. The old site setting names remain functional, so security-related overrides will remain working. A follow-up commit will be made with a post-deploy migration to delete the old `site_settings` rows.
2021-02-08 18:04:33 +08:00
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
FEATURE: Rename 'Discourse SSO' to DiscourseConnect (#11978) The 'Discourse SSO' protocol is being rebranded to DiscourseConnect. This should help to reduce confusion when 'SSO' is used in the generic sense. This commit aims to: - Rename `sso_` site settings. DiscourseConnect specific ones are prefixed `discourse_connect_`. Generic settings are prefixed `auth_` - Add (server-side-only) backwards compatibility for the old setting names, with deprecation notices - Copy `site_settings` database records to the new names - Rename relevant translation keys - Update relevant translations This commit does **not** aim to: - Rename any Ruby classes or methods. This might be done in a future commit - Change any URLs. This would break existing integrations - Make any changes to the protocol. This would break existing integrations - Change any functionality. Further normalization across DiscourseConnect and other auth methods will be done separately The risks are: - There is no backwards compatibility for site settings on the client-side. Accessing auth-related site settings in Javascript is fairly rare, and an error on the client side would not be security-critical. - If a plugin is monkey-patching parts of the auth process, changes to locale keys could cause broken error messages. This should also be unlikely. The old site setting names remain functional, so security-related overrides will remain working. A follow-up commit will be made with a post-deploy migration to delete the old `site_settings` rows.
2021-02-08 18:04:33 +08:00
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