diff --git a/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 b/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 index 0d06a8d0adc..266fdbd02c4 100644 --- a/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 @@ -70,7 +70,9 @@ export default Ember.Controller.extend(CanCheckEmails, { unsuspend() { this.get("model").unsuspend().catch(popupAjaxError); }, - + showSilenceModal() { + this.get('adminTools').showSilenceModal(this.get('model')); + }, toggleUsernameEdit() { this.set('userUsernameValue', this.get('model.username')); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6 new file mode 100644 index 00000000000..9f1aef916f8 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6 @@ -0,0 +1,50 @@ +import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import computed from 'ember-addons/ember-computed-decorators'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; + +export default Ember.Controller.extend(ModalFunctionality, { + silenceUntil: null, + reason: null, + message: null, + silencing: false, + user: null, + post: null, + successCallback: null, + + onShow() { + this.setProperties({ + silenceUntil: null, + reason: null, + message: null, + silencing: false, + loadingUser: true, + post: null, + successCallback: null, + }); + }, + + @computed('silenceUntil', 'reason', 'silencing') + submitDisabled(silenceUntil, reason, silencing) { + return (silencing || Ember.isEmpty(silenceUntil) || !reason || reason.length < 1); + }, + + actions: { + silence() { + if (this.get('submitDisabled')) { return; } + + this.set('silencing', true); + this.get('user').silence({ + silenced_till: this.get('silenceUntil'), + reason: this.get('reason'), + message: this.get('message'), + post_id: this.get('post.id') + }).then(result => { + this.send('closeModal'); + let callback = this.get('successCallback'); + if (callback) { + callback(result); + } + }).catch(popupAjaxError).finally(() => this.set('silencing', false)); + } + } +}); diff --git a/app/assets/javascripts/admin/models/admin-user.js.es6 b/app/assets/javascripts/admin/models/admin-user.js.es6 index a162c0f319f..e3f1530badf 100644 --- a/app/assets/javascripts/admin/models/admin-user.js.es6 +++ b/app/assets/javascripts/admin/models/admin-user.js.es6 @@ -8,6 +8,8 @@ import Group from 'discourse/models/group'; import TL3Requirements from 'admin/models/tl3-requirements'; import { userPath } from 'discourse/lib/url'; +const wrapAdmin = user => user ? AdminUser.create(user) : null; + const AdminUser = Discourse.User.extend({ adminUserView: true, customGroups: Ember.computed.filter("groups", g => !g.automatic && Group.create(g)), @@ -232,6 +234,7 @@ const AdminUser = Discourse.User.extend({ }.property('trust_level'), isSuspended: Em.computed.equal('suspended', true), + isSilenced: Ember.computed.equal('silenced', true), canSuspend: Em.computed.not('staff'), suspendDuration: function() { @@ -301,44 +304,36 @@ const AdminUser = Discourse.User.extend({ unsilence() { this.set('silencingUser', true); - return ajax('/admin/users/' + this.id + '/unsilence', { + + return ajax(`/admin/users/${this.id}/unsilence`, { type: 'PUT' - }).then(function() { - window.location.reload(); - }).catch(function(e) { - var error = I18n.t('admin.user.unsilence_failed', { error: "http: " + e.status + " - " + e.body }); + }).then(result => { + this.setProperties(result.unsilence); + }).catch(e => { + let error = I18n.t('admin.user.unsilence_failed', { + error: `http: ${e.status} - ${e.body}` + }); bootbox.alert(error); + }).finally(() => { + this.set('silencingUser', false); }); }, - silence() { - const user = this, - message = I18n.t("admin.user.silence_confirm"); - - const performSilence = function() { - user.set('silencingUser', true); - return ajax('/admin/users/' + user.id + '/silence', { - type: 'PUT' - }).then(function() { - window.location.reload(); - }).catch(function(e) { - var error = I18n.t('admin.user.silence_failed', { error: "http: " + e.status + " - " + e.body }); - bootbox.alert(error); - user.set('silencingUser', false); + silence(data) { + this.set('silencingUser', true); + return ajax(`/admin/users/${this.id}/silence`, { + type: 'PUT', + data + }).then(result => { + this.setProperties(result.silence); + }).catch(e => { + let error = I18n.t('admin.user.silence_failed', { + error: `http: ${e.status} - ${e.body}` }); - }; - - const buttons = [{ - "label": I18n.t("composer.cancel"), - "class": "cancel", - "link": true - }, { - "label": `${iconHTML('exclamation-triangle')} ` + I18n.t('admin.user.silence_accept'), - "class": "btn btn-danger", - "callback": function() { performSilence(); } - }]; - - bootbox.dialog(message, buttons, { "classes": "delete-user-modal" }); + bootbox.alert(error); + }).finally(() => { + this.set('silencingUser', false); + }); }, sendActivationEmail() { @@ -475,17 +470,14 @@ const AdminUser = Discourse.User.extend({ } }.property('tl3_requirements'), - suspendedBy: function() { - if (this.get('suspended_by')) { - return AdminUser.create(this.get('suspended_by')); - } - }.property('suspended_by'), + @computed('suspended_by') + suspendedBy: wrapAdmin, - approvedBy: function() { - if (this.get('approved_by')) { - return AdminUser.create(this.get('approved_by')); - } - }.property('approved_by') + @computed('silenced_by') + silencedBy: wrapAdmin, + + @computed('approved_by') + approvedBy: wrapAdmin, }); diff --git a/app/assets/javascripts/admin/services/admin-tools.js.es6 b/app/assets/javascripts/admin/services/admin-tools.js.es6 index 9f855772a22..987c7c3d645 100644 --- a/app/assets/javascripts/admin/services/admin-tools.js.es6 +++ b/app/assets/javascripts/admin/services/admin-tools.js.es6 @@ -20,12 +20,12 @@ export default Ember.Service.extend({ }; }, - showSuspendModal(user, opts) { + _showControlModal(type, user, opts) { opts = opts || {}; - let controller = showModal('admin-suspend-user', { + let controller = showModal(`admin-${type}-user`, { admin: true, - modalClass: 'suspend-user-modal' + modalClass: `${type}-user-modal` }); if (opts.post) { controller.set('post', opts.post); @@ -44,6 +44,14 @@ export default Ember.Service.extend({ }); }, + showSilenceModal(user, opts) { + this._showControlModal('silence', user, opts); + }, + + showSuspendModal(user, opts) { + this._showControlModal('suspend', user, opts); + }, + _deleteSpammer(adminUser) { return adminUser.checkEmail().then(() => { diff --git a/app/assets/javascripts/admin/templates/modal/admin-silence-user.hbs b/app/assets/javascripts/admin/templates/modal/admin-silence-user.hbs new file mode 100644 index 00000000000..9bc92171160 --- /dev/null +++ b/app/assets/javascripts/admin/templates/modal/admin-silence-user.hbs @@ -0,0 +1,50 @@ +{{#d-modal-body title="admin.user.silence_modal_title"}} + {{#conditional-loading-spinner condition=loadingUser}} + +
+ +
+ +
+ +
+ + + + {{/conditional-loading-spinner}} + +{{/d-modal-body}} + + diff --git a/app/assets/javascripts/admin/templates/user-index.hbs b/app/assets/javascripts/admin/templates/user-index.hbs index 9eea948704a..a0c7741475e 100644 --- a/app/assets/javascripts/admin/templates/user-index.hbs +++ b/app/assets/javascripts/admin/templates/user-index.hbs @@ -349,19 +349,49 @@
{{i18n 'admin.user.silenced'}}
-
{{i18n-yes-no model.silenced}}
+
+ {{i18n-yes-no model.silenced}} + {{#if model.isSilenced}} + {{#unless model.silencedForever}} + {{i18n "admin.user.suspended_until" until=model.silencedTillDate}} + {{/unless}} + {{/if}} +
{{#conditional-loading-spinner size="small" condition=model.silencingUser}} {{#if model.silenced}} - {{d-button action="unsilence" icon="thumbs-o-up" label="admin.user.unsilence"}} + {{d-button + class="btn-danger unsilence-user" + action="unsilence" + icon="microphone-slash" + label="admin.user.unsilence"}} {{i18n 'admin.user.silence_explanation'}} {{else}} - {{d-button action="silence" icon="ban" label="admin.user.silence"}} + {{d-button + class="btn-danger silence-user" + action=(action "showSilenceModal") + icon="microphone-slash" + label="admin.user.silence"}} {{i18n 'admin.user.silence_explanation'}} {{/if}} {{/conditional-loading-spinner}}
+ + {{#if model.isSilenced}} +
+
{{i18n 'admin.user.silenced_by'}}
+
+ {{#link-to 'adminUser' silencedBy}}{{avatar model.silencedBy imageSize="tiny"}}{{/link-to}} + {{#link-to 'adminUser' silencedBy}}{{model.silencedBy.username}}{{/link-to}} +
+
+ {{i18n 'admin.user.silence_reason'}}: + {{model.silence_reason}} +
+
+ {{/if}} + {{#if currentUser.admin}} diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6 index cc32dda9271..9925d4798a0 100644 --- a/app/assets/javascripts/discourse/models/user.js.es6 +++ b/app/assets/javascripts/discourse/models/user.js.es6 @@ -16,6 +16,8 @@ import PreloadStore from 'preload-store'; import { defaultHomepage } from 'discourse/lib/utilities'; import { userPath } from 'discourse/lib/url'; +const isForever = dt => moment().diff(dt, 'years') < -500; + const User = RestModel.extend({ hasPMs: Em.computed.gt("private_messages_stats.all", 0), @@ -178,14 +180,16 @@ const User = RestModel.extend({ }, @computed("suspended_till") - suspendedForever(suspendedTill) { - return moment().diff(suspendedTill, 'years') < -500; - }, + suspendedForever: isForever, + + @computed("silenced_till") + silencedForever: isForever, @computed("suspended_till") - suspendedTillDate(suspendedTill) { - return longDate(suspendedTill); - }, + suspendedTillDate: longDate, + + @computed("silenced_till") + silencedTillDate: longDate, changeUsername(new_username) { return ajax(userPath(`${this.get('username_lower')}/preferences/username`), { diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 3228bb4e3a6..95a7016b3be 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -274,14 +274,48 @@ class Admin::UsersController < Admin::AdminController def silence guardian.ensure_can_silence_user! @user - UserSilencer.silence(@user, current_user, keep_posts: true) - render body: nil + + message = params[:message] + + silencer = UserSilencer.new( + @user, + current_user, + silenced_till: params[:silenced_till], + reason: params[:reason], + context: message, + keep_posts: true + ) + if silencer.silence && message.present? + Jobs.enqueue( + :critical_user_email, + type: :account_silenced, + user_id: @user.id, + user_history_id: silencer.user_history.id + ) + end + + render_json_dump( + silence: { + silenced: true, + silence_reason: params[:reason], + silenced_till: @user.silenced_till, + suspended_at: @user.silenced_at + } + ) end def unsilence guardian.ensure_can_unsilence_user! @user UserSilencer.unsilence(@user, current_user) - render body: nil + + render_json_dump( + unsilence: { + silenced: false, + silence_reason: nil, + silenced_till: nil, + suspended_at: nil + } + ) end def reject_bulk diff --git a/app/jobs/regular/export_csv_file.rb b/app/jobs/regular/export_csv_file.rb index 9354f4288d8..1697792b64a 100644 --- a/app/jobs/regular/export_csv_file.rb +++ b/app/jobs/regular/export_csv_file.rb @@ -10,7 +10,7 @@ module Jobs HEADER_ATTRS_FOR ||= HashWithIndifferentAccess.new( user_archive: ['topic_title', 'category', 'sub_category', 'is_pm', 'post', 'like_count', 'reply_count', 'url', 'created_at'], - user_list: ['id', 'name', 'username', 'email', 'title', 'created_at', 'last_seen_at', 'last_posted_at', 'last_emailed_at', 'trust_level', 'approved', 'suspended_at', 'suspended_till', 'silenced', 'active', 'admin', 'moderator', 'ip_address', 'staged'], + user_list: ['id', 'name', 'username', 'email', 'title', 'created_at', 'last_seen_at', 'last_posted_at', 'last_emailed_at', 'trust_level', 'approved', 'suspended_at', 'suspended_till', 'silenced_till', 'active', 'admin', 'moderator', 'ip_address', 'staged'], user_stats: ['topics_entered', 'posts_read_count', 'time_read', 'topic_count', 'post_count', 'likes_given', 'likes_received'], user_profile: ['location', 'website', 'views'], user_sso: ['external_id', 'external_email', 'external_username', 'external_name', 'external_avatar_url'], @@ -181,7 +181,7 @@ module Jobs def get_base_user_array(user) user_array = [] - user_array.push(user.id, escape_comma(user.name), user.username, user.email, escape_comma(user.title), user.created_at, user.last_seen_at, user.last_posted_at, user.last_emailed_at, user.trust_level, user.approved, user.suspended_at, user.suspended_till, user.silenced, user.active, user.admin, user.moderator, user.ip_address, user.staged, user.user_stat.topics_entered, user.user_stat.posts_read_count, user.user_stat.time_read, user.user_stat.topic_count, user.user_stat.post_count, user.user_stat.likes_given, user.user_stat.likes_received, escape_comma(user.user_profile.location), user.user_profile.website, user.user_profile.views) + user_array.push(user.id, escape_comma(user.name), user.username, user.email, escape_comma(user.title), user.created_at, user.last_seen_at, user.last_posted_at, user.last_emailed_at, user.trust_level, user.approved, user.suspended_at, user.suspended_till, user.silenced_till, user.active, user.admin, user.moderator, user.ip_address, user.staged, user.user_stat.topics_entered, user.user_stat.posts_read_count, user.user_stat.time_read, user.user_stat.topic_count, user.user_stat.post_count, user.user_stat.likes_given, user.user_stat.likes_received, escape_comma(user.user_profile.location), user.user_profile.website, user.user_profile.views) end def add_single_sign_on(user, user_info_array) diff --git a/app/jobs/scheduled/grant_anniversary_badges.rb b/app/jobs/scheduled/grant_anniversary_badges.rb index 7a4da06d30a..6d0e24bd12d 100644 --- a/app/jobs/scheduled/grant_anniversary_badges.rb +++ b/app/jobs/scheduled/grant_anniversary_badges.rb @@ -22,7 +22,7 @@ module Jobs ub.badge_id = #{Badge::Anniversary} AND ub.granted_at BETWEEN '#{fmt_start_date}' AND '#{fmt_end_date}' WHERE u.active AND - NOT u.silenced AND + u.silenced_till IS NULL AND NOT p.hidden AND p.deleted_at IS NULL AND t.visible AND diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index e1d24b37c6a..236acc0dabd 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -68,6 +68,21 @@ class UserNotifications < ActionMailer::Base email_token: opts[:email_token]) end + def account_silenced(user, opts = nil) + opts ||= {} + + return unless user_history = opts[:user_history] + + build_email( + user.email, + template: "user_notifications.account_silenced", + locale: user_locale(user), + reason: user_history.details, + message: user_history.context, + silenced_till: I18n.l(user.silenced_till, format: :long) + ) + end + def account_suspended(user, opts = nil) opts ||= {} diff --git a/app/models/directory_item.rb b/app/models/directory_item.rb index 584ce6a7097..8bcf2bbd065 100644 --- a/app/models/directory_item.rb +++ b/app/models/directory_item.rb @@ -82,7 +82,7 @@ class DirectoryItem < ActiveRecord::Base LEFT OUTER JOIN posts AS p ON ua.target_post_id = p.id LEFT OUTER JOIN categories AS c ON t.category_id = c.id WHERE u.active - AND NOT u.silenced + AND u.silenced_till IS NULL AND t.deleted_at IS NULL AND COALESCE(t.visible, true) AND p.deleted_at IS NULL diff --git a/app/models/user.rb b/app/models/user.rb index 7e3fe29d1e8..f7398f6374e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -147,8 +147,8 @@ class User < ActiveRecord::Base # TODO-PERF: There is no indexes on any of these # and NotifyMailingListSubscribers does a select-all-and-loop # may want to create an index on (active, silence, suspended_till)? - scope :silenced, -> { where(silenced: true) } - scope :not_silenced, -> { where(silenced: false) } + scope :silenced, -> { where("silenced_till IS NOT NULL AND silenced_till > ?", Time.zone.now) } + scope :not_silenced, -> { where("silenced_till IS NULL OR silenced_till <= ?", Time.zone.now) } scope :suspended, -> { where('suspended_till IS NOT NULL AND suspended_till > ?', Time.zone.now) } scope :not_suspended, -> { where('suspended_till IS NULL OR suspended_till <= ?', Time.zone.now) } scope :activated, -> { where(active: true) } @@ -660,6 +660,22 @@ class User < ActiveRecord::Base !!(suspended_till && suspended_till > DateTime.now) end + def silenced? + !!(silenced_till && silenced_till > DateTime.now) + end + + def silenced_record + UserHistory.for(self, :silence_user).order('id DESC').first + end + + def silence_reason + silenced_record.try(:details) if silenced? + end + + def silenced_at + silenced_record.try(:created_at) if silenced? + end + def suspend_record UserHistory.for(self, :suspend_user).order('id DESC').first end diff --git a/app/serializers/admin_detailed_user_serializer.rb b/app/serializers/admin_detailed_user_serializer.rb index 6df084c7274..e3e488415ce 100644 --- a/app/serializers/admin_detailed_user_serializer.rb +++ b/app/serializers/admin_detailed_user_serializer.rb @@ -18,6 +18,7 @@ class AdminDetailedUserSerializer < AdminUserSerializer :can_be_anonymized, :suspend_reason, :suspended_till, + :silence_reason, :primary_group_id, :badge_count, :warnings_received_count, @@ -29,6 +30,7 @@ class AdminDetailedUserSerializer < AdminUserSerializer has_one :approved_by, serializer: BasicUserSerializer, embed: :objects has_one :api_key, serializer: ApiKeySerializer, embed: :objects has_one :suspended_by, serializer: BasicUserSerializer, embed: :objects + has_one :silenced_by, serializer: BasicUserSerializer, embed: :objects has_one :tl3_requirements, serializer: TrustLevel3RequirementsSerializer, embed: :objects has_many :groups, embed: :object, serializer: BasicGroupSerializer @@ -72,6 +74,14 @@ class AdminDetailedUserSerializer < AdminUserSerializer object.suspend_record.try(:acting_user) end + def silence_reason + object.silence_reason + end + + def silenced_by + object.silenced_record.try(:acting_user) + end + def include_tl3_requirements? object.has_trust_level?(TrustLevel[2]) end diff --git a/app/serializers/admin_user_list_serializer.rb b/app/serializers/admin_user_list_serializer.rb index 22d2e07879c..251ba21035e 100644 --- a/app/serializers/admin_user_list_serializer.rb +++ b/app/serializers/admin_user_list_serializer.rb @@ -23,6 +23,7 @@ class AdminUserListSerializer < BasicUserSerializer :suspended_till, :suspended, :silenced, + :silenced_till, :time_read, :staged @@ -40,6 +41,22 @@ class AdminUserListSerializer < BasicUserSerializer alias_method :include_associated_accounts?, :include_email? + def silenced + object.silenced? + end + + def include_silenced? + object.silenced? + end + + def silenced_till + object.silenced_till + end + + def include_silenced_till? + object.silenced_till? + end + def suspended object.suspended? end diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb index 7232490b3c4..0dc4543774d 100644 --- a/app/services/staff_action_logger.rb +++ b/app/services/staff_action_logger.rb @@ -275,8 +275,13 @@ class StaffActionLogger def log_silence_user(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create(params(opts).merge(action: UserHistory.actions[:silence_user], - target_user_id: user.id)) + UserHistory.create( + params(opts).merge( + action: UserHistory.actions[:silence_user], + target_user_id: user.id, + details: opts[:details] + ) + ) end def log_unsilence_user(user, opts = {}) diff --git a/app/services/user_silencer.rb b/app/services/user_silencer.rb index 63061f8b4bd..ee7981b5f10 100644 --- a/app/services/user_silencer.rb +++ b/app/services/user_silencer.rb @@ -1,5 +1,7 @@ class UserSilencer + attr_reader :user_history + def initialize(user, by_user = nil, opts = {}) @user, @by_user, @opts = user, by_user, opts end @@ -14,14 +16,26 @@ class UserSilencer def silence hide_posts unless @opts[:keep_posts] - unless @user.silenced? - @user.silenced = true + unless @user.silenced_till.present? + @user.silenced_till = @opts[:silenced_till] || 1000.years.from_now if @user.save message_type = @opts[:message] || :silenced_by_staff - post = SystemMessage.create(@user, message_type) - if post && @by_user - StaffActionLogger.new(@by_user).log_silence_user(@user, context: "#{message_type}: '#{post.topic&.title rescue ''}' #{@opts[:reason]}") + + if @opts[:context].present? + context = @opts[:context] + else + context = "#{message_type}: '#{post.topic&.title rescue ''}' #{@opts[:reason]}" + SystemMessage.create(@user, message_type) end + + if @by_user + @user_history = StaffActionLogger.new(@by_user).log_silence_user( + @user, + context: context, + details: @opts[:reason] + ) + end + return true end else false @@ -37,7 +51,7 @@ class UserSilencer end def unsilence - @user.silenced = false + @user.silenced_till = nil if @user.save SystemMessage.create(@user, :unsilenced) StaffActionLogger.new(@by_user).log_unsilence_user(@user) if @by_user diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 80a49840fd9..1114bf2ba62 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3281,6 +3281,14 @@ en: suspend_message: "Email Message" suspend_message_placeholder: "Optionally, provide more information about the suspension and it will be emailed to the user." suspended_by: "Suspended by" + silence_reason: "Reason" + silenced_by: "Silenced By" + silence_modal_title: "Silence User" + silence_duration: "How long will the user be silenced for?" + silence_reason_label: "Why are you silencing this user?" + silence_reason_placeholder: "Silence Reason" + silence_message: "Email Message" + silence_message_placeholder: "(leave blank to send default message)" suspended_until: "(until %{until})" cant_suspend: "This user cannot be suspended." delete_all_posts: "Delete all posts" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index cb518bac1bf..140726dca0a 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -2660,6 +2660,17 @@ en: %{message} + account_silenced: + title: "Account Silenced" + subject_template: "[%{email_prefix}] Your account has been silenced" + text_body_template: | + You have been silenced from the forum until %{silenced_till}. + + %{reason} + + %{message} + + account_exists: title: "Account already exists" subject_template: "[%{email_prefix}] Account already exists" diff --git a/db/fixtures/009_users.rb b/db/fixtures/009_users.rb index ae6588ce471..2f49f9d3148 100644 --- a/db/fixtures/009_users.rb +++ b/db/fixtures/009_users.rb @@ -72,6 +72,17 @@ ColumnDropper.drop( } ) +ColumnDropper.drop( + table: 'users', + after_migration: 'AddSilencedTillToUsers', + columns: %w[ + silenced + ], + on_drop: ->() { + STDERR.puts 'Removing user silenced column!' + } +) + # User for the smoke tests if ENV["SMOKE"] == "1" UserEmail.seed do |ue| diff --git a/db/migrate/20171113175414_add_silenced_till_to_users.rb b/db/migrate/20171113175414_add_silenced_till_to_users.rb new file mode 100644 index 00000000000..33552a38987 --- /dev/null +++ b/db/migrate/20171113175414_add_silenced_till_to_users.rb @@ -0,0 +1,14 @@ +class AddSilencedTillToUsers < ActiveRecord::Migration[5.1] + def up + add_column :users, :silenced_till, :timestamp, null: true + execute <<~SQL + UPDATE users + SET silenced_till = CURRENT_TIMESTAMP + INTERVAL '1000 YEAR' + WHERE silenced + SQL + end + + def down + add_column :users, :silenced_till + end +end diff --git a/lib/badge_queries.rb b/lib/badge_queries.rb index 5878293a6da..c2ce008150a 100644 --- a/lib/badge_queries.rb +++ b/lib/badge_queries.rb @@ -141,10 +141,10 @@ SQL SELECT invited_by_id FROM invites i JOIN users u2 ON u2.id = i.user_id - WHERE i.deleted_at IS NULL AND u2.active AND u2.trust_level >= #{trust_level.to_i} AND not u2.silenced + WHERE i.deleted_at IS NULL AND u2.active AND u2.trust_level >= #{trust_level.to_i} AND u2.silenced_till IS NULL GROUP BY invited_by_id HAVING COUNT(*) >= #{count.to_i} - ) AND u.active AND NOT u.silenced AND u.id > 0 AND + ) AND u.active AND u.silenced_till IS NULL AND u.id > 0 AND (:backfill OR u.id IN (:user_ids) ) " end diff --git a/spec/components/admin_user_index_query_spec.rb b/spec/components/admin_user_index_query_spec.rb index de124a5be8e..6604b48d52a 100644 --- a/spec/components/admin_user_index_query_spec.rb +++ b/spec/components/admin_user_index_query_spec.rb @@ -167,7 +167,7 @@ describe AdminUserIndexQuery do describe "with a silenced user" do - let!(:user) { Fabricate(:user, silenced: true) } + let!(:user) { Fabricate(:user, silenced_till: 1.year.from_now) } it "finds the silenced user" do query = ::AdminUserIndexQuery.new(query: 'silenced') diff --git a/spec/components/email/receiver_spec.rb b/spec/components/email/receiver_spec.rb index 39451cb49bf..cf36f2d8e78 100644 --- a/spec/components/email/receiver_spec.rb +++ b/spec/components/email/receiver_spec.rb @@ -57,7 +57,7 @@ describe Email::Receiver do end it "raises a SilencedUserError when the sender has been silenced" do - Fabricate(:user, email: "silenced@bar.com", silenced: true) + Fabricate(:user, email: "silenced@bar.com", silenced_till: 1.year.from_now) expect { process(:silenced_sender) }.to raise_error(Email::Receiver::SilencedUserError) end diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb index 277e2f408d3..16bd0378f5c 100644 --- a/spec/components/guardian_spec.rb +++ b/spec/components/guardian_spec.rb @@ -195,7 +195,7 @@ describe Guardian do context "author is silenced" do before do - user.silenced = true + user.silenced_till = 1.year.from_now user.save end @@ -853,13 +853,13 @@ describe Guardian do end it "allows new posts from silenced users included in the pm" do - user.update_attribute(:silenced, true) + user.update_attribute(:silenced_till, 1.year.from_now) private_message.topic_allowed_users.create!(user_id: user.id) expect(Guardian.new(user).can_create?(Post, private_message)).to be_truthy end it "doesn't allow new posts from silenced users not invited to the pm" do - user.update_attribute(:silenced, true) + user.update_attribute(:silenced_till, 1.year.from_now) expect(Guardian.new(user).can_create?(Post, private_message)).to be_falsey end end @@ -1376,7 +1376,7 @@ describe Guardian do context 'when user is silenced' do it 'returns false' do - user.toggle!(:silenced) + user.update_column(:silenced_till, 1.year.from_now) expect(Guardian.new(user).can_moderate?(post)).to be(false) expect(Guardian.new(user).can_moderate?(topic)).to be(false) end diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 629f12c80ab..78f298a4642 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -495,15 +495,7 @@ describe Admin::UsersController do end context '.destroy' do - before do - @delete_me = Fabricate(:user) - end - - it "raises an error when the user doesn't have permission" do - Guardian.any_instance.expects(:can_delete_user?).with(@delete_me).returns(false) - delete :destroy, params: { id: @delete_me.id }, format: :json - expect(response).to be_forbidden - end + let(:delete_me) { Fabricate(:user) } it "returns a 403 if the user doesn't exist" do delete :destroy, params: { id: 123123 }, format: :json @@ -511,31 +503,26 @@ describe Admin::UsersController do end context "user has post" do + let(:topic) { create_topic(user: delete_me) } before do - @user = Fabricate(:user) - topic = create_topic(user: @user) - _post = create_post(topic: topic, user: @user) - @user.stubs(:first_post_created_at).returns(Time.zone.now) - User.expects(:find_by).with(id: @delete_me.id).returns(@user) + _post = create_post(topic: topic, user: delete_me) end it "returns an error" do - delete :destroy, params: { id: @delete_me.id }, format: :json + delete :destroy, params: { id: delete_me.id }, format: :json expect(response).to be_forbidden end it "doesn't return an error if delete_posts == true" do - UserDestroyer.any_instance.expects(:destroy).with(@user, has_entry('delete_posts' => true)).returns(true) - delete :destroy, params: { id: @delete_me.id, delete_posts: true }, format: :json + delete :destroy, params: { id: delete_me.id, delete_posts: true }, format: :json expect(response).to be_success end - end it "deletes the user record" do UserDestroyer.any_instance.expects(:destroy).returns(true) - delete :destroy, params: { id: @delete_me.id }, format: :json + delete :destroy, params: { id: delete_me.id }, format: :json end end @@ -590,9 +577,10 @@ describe Admin::UsersController do it "raises an error when the user doesn't have permission" do Guardian.any_instance.expects(:can_silence_user?).with(@reg_user).returns(false) - UserSilencer.expects(:silence).never put :silence, params: { user_id: @reg_user.id }, format: :json expect(response).to be_forbidden + @reg_user.reload + expect(@reg_user).not_to be_silenced end it "returns a 403 if the user doesn't exist" do @@ -601,8 +589,43 @@ describe Admin::UsersController do end it "punishes the user for spamming" do - UserSilencer.expects(:silence).with(@reg_user, @user, anything) put :silence, params: { user_id: @reg_user.id }, format: :json + expect(response).to be_success + @reg_user.reload + expect(@reg_user).to be_silenced + end + + it "will set a length of time if provided" do + future_date = 1.month.from_now.to_date + put( + :silence, + params: { + user_id: @reg_user.id, + silenced_till: future_date + }, + format: :json + ) + @reg_user.reload + expect(@reg_user.silenced_till).to eq(future_date) + end + + it "will send a message if provided" do + Jobs.expects(:enqueue).with( + :critical_user_email, + has_entries( + type: :account_silenced, + user_id: @reg_user.id + ) + ) + + put( + :silence, + params: { + user_id: @reg_user.id, + message: "Email this to the user" + }, + format: :json + ) end end diff --git a/spec/controllers/posts_controller_spec.rb b/spec/controllers/posts_controller_spec.rb index eda2fa09c86..1981d15bf68 100644 --- a/spec/controllers/posts_controller_spec.rb +++ b/spec/controllers/posts_controller_spec.rb @@ -710,7 +710,7 @@ describe PostsController do expect(parsed["action"]).to eq("enqueued") user.reload - expect(user.silenced).to eq(true) + expect(user).to be_silenced qp = QueuedPost.first @@ -718,7 +718,7 @@ describe PostsController do qp.approve!(mod) user.reload - expect(user.silenced).to eq(false) + expect(user).not_to be_silenced end it "doesn't enqueue replies when the topic is closed" do @@ -763,7 +763,7 @@ describe PostsController do expect(parsed["action"]).to eq("enqueued") user.reload - expect(user.silenced).to eq(true) + expect(user).to be_silenced end it "can send a message to a group" do diff --git a/spec/jobs/grant_anniversary_badges_spec.rb b/spec/jobs/grant_anniversary_badges_spec.rb index d1664eaa2cc..a73a4b719d0 100644 --- a/spec/jobs/grant_anniversary_badges_spec.rb +++ b/spec/jobs/grant_anniversary_badges_spec.rb @@ -24,7 +24,7 @@ describe Jobs::GrantAnniversaryBadges do end it "doesn't award to a silenced user" do - user = Fabricate(:user, created_at: 400.days.ago, silenced: true) + user = Fabricate(:user, created_at: 400.days.ago, silenced_till: 1.year.from_now) Fabricate(:post, user: user, created_at: 1.week.ago) granter.execute({}) diff --git a/spec/jobs/notify_mailing_list_subscribers_spec.rb b/spec/jobs/notify_mailing_list_subscribers_spec.rb index 131dd780c26..f6252b9120a 100644 --- a/spec/jobs/notify_mailing_list_subscribers_spec.rb +++ b/spec/jobs/notify_mailing_list_subscribers_spec.rb @@ -66,7 +66,7 @@ describe Jobs::NotifyMailingListSubscribers do end context "to a silenced user" do - before { mailing_list_user.update(silenced: true) } + before { mailing_list_user.update(silenced_till: 1.year.from_now) } include_examples "no emails" end diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 0e527e92eb2..0d85b51daa6 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -677,7 +677,7 @@ describe Topic do context "when moderator post fails to be created" do before do - user.toggle!(:silenced) + user.update_column(:silenced_till, 1.year.from_now) end it "should not increment moderator_posts_count" do @@ -833,7 +833,7 @@ describe Topic do it_should_behave_like 'a status that closes a topic' context 'topic was set to close when it was created' do - it 'puts the autoclose duration in the moderator post' do + it 'includes the autoclose duration in the moderator post' do freeze_time(Time.new(2000, 1, 1)) @topic.created_at = 3.days.ago @topic.update_status(status, true, @user) @@ -842,7 +842,7 @@ describe Topic do end context 'topic was set to close after it was created' do - it 'puts the autoclose duration in the moderator post' do + it 'includes the autoclose duration in the moderator post' do freeze_time(Time.new(2000, 1, 1)) @topic.created_at = 7.days.ago diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 4f4071bffa0..700b77fb160 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1532,4 +1532,32 @@ describe User do ]) end end + + describe "silenced?" do + + it "is not silenced by default" do + expect(Fabricate(:user)).not_to be_silenced + end + + it "is not silenced with a date in the past" do + expect(Fabricate(:user, silenced_till: 1.month.ago)).not_to be_silenced + end + + it "is is silenced with a date in the future" do + expect(Fabricate(:user, silenced_till: 1.month.from_now)).to be_silenced + end + + context "finders" do + let!(:user0) { Fabricate(:user, silenced_till: 1.month.ago) } + let!(:user1) { Fabricate(:user, silenced_till: 1.month.from_now) } + + it "doesn't return old silenced records" do + expect(User.silenced).to_not include(user0) + expect(User.silenced).to include(user1) + expect(User.not_silenced).to include(user0) + expect(User.not_silenced).to_not include(user1) + end + end + end + end diff --git a/spec/services/auto_silence_spec.rb b/spec/services/auto_silence_spec.rb index 3ee3269e4c6..4a21a55f135 100644 --- a/spec/services/auto_silence_spec.rb +++ b/spec/services/auto_silence_spec.rb @@ -275,7 +275,7 @@ describe SpamRule::AutoSilence do end context "silenced, but has higher trust level now" do - let(:user) { Fabricate(:user, silenced: true, trust_level: TrustLevel[1]) } + let(:user) { Fabricate(:user, silenced_till: 1.year.from_now, trust_level: TrustLevel[1]) } subject { described_class.new(user) } it 'returns false' do diff --git a/spec/services/user_silencer_spec.rb b/spec/services/user_silencer_spec.rb index 9b230575633..188bd0435fd 100644 --- a/spec/services/user_silencer_spec.rb +++ b/spec/services/user_silencer_spec.rb @@ -7,8 +7,8 @@ describe UserSilencer do end describe 'silence' do - let(:user) { stub_everything(save: true) } - let(:silencer) { UserSilencer.new(user) } + let(:user) { Fabricate(:user) } + let(:silencer) { UserSilencer.new(user) } subject(:silence_user) { silencer.silence } it 'silences the user' do @@ -53,7 +53,7 @@ describe UserSilencer do end it "doesn't send a pm if the user is already silenced" do - user.stubs(:silenced?).returns(true) + user.silenced_till = 1.year.from_now SystemMessage.unstub(:create) SystemMessage.expects(:create).never expect(silence_user).to eq(false) @@ -73,7 +73,7 @@ describe UserSilencer do subject(:unsilence_user) { UserSilencer.unsilence(user, Fabricate.build(:admin)) } it 'unsilences the user' do - u = Fabricate(:user, silenced: true) + u = Fabricate(:user, silenced_till: 1.year.from_now) expect { UserSilencer.unsilence(u) }.to change { u.reload.silenced? } end