FEATURE: Support an end date for user silencing

This commit is contained in:
Robin Ward 2017-11-13 13:41:36 -05:00
parent 52480d554a
commit 971e302ff2
33 changed files with 456 additions and 114 deletions

View File

@ -70,7 +70,9 @@ export default Ember.Controller.extend(CanCheckEmails, {
unsuspend() { unsuspend() {
this.get("model").unsuspend().catch(popupAjaxError); this.get("model").unsuspend().catch(popupAjaxError);
}, },
showSilenceModal() {
this.get('adminTools').showSilenceModal(this.get('model'));
},
toggleUsernameEdit() { toggleUsernameEdit() {
this.set('userUsernameValue', this.get('model.username')); this.set('userUsernameValue', this.get('model.username'));

View File

@ -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));
}
}
});

View File

@ -8,6 +8,8 @@ import Group from 'discourse/models/group';
import TL3Requirements from 'admin/models/tl3-requirements'; import TL3Requirements from 'admin/models/tl3-requirements';
import { userPath } from 'discourse/lib/url'; import { userPath } from 'discourse/lib/url';
const wrapAdmin = user => user ? AdminUser.create(user) : null;
const AdminUser = Discourse.User.extend({ const AdminUser = Discourse.User.extend({
adminUserView: true, adminUserView: true,
customGroups: Ember.computed.filter("groups", g => !g.automatic && Group.create(g)), customGroups: Ember.computed.filter("groups", g => !g.automatic && Group.create(g)),
@ -232,6 +234,7 @@ const AdminUser = Discourse.User.extend({
}.property('trust_level'), }.property('trust_level'),
isSuspended: Em.computed.equal('suspended', true), isSuspended: Em.computed.equal('suspended', true),
isSilenced: Ember.computed.equal('silenced', true),
canSuspend: Em.computed.not('staff'), canSuspend: Em.computed.not('staff'),
suspendDuration: function() { suspendDuration: function() {
@ -301,44 +304,36 @@ const AdminUser = Discourse.User.extend({
unsilence() { unsilence() {
this.set('silencingUser', true); this.set('silencingUser', true);
return ajax('/admin/users/' + this.id + '/unsilence', {
return ajax(`/admin/users/${this.id}/unsilence`, {
type: 'PUT' type: 'PUT'
}).then(function() { }).then(result => {
window.location.reload(); this.setProperties(result.unsilence);
}).catch(function(e) { }).catch(e => {
var error = I18n.t('admin.user.unsilence_failed', { error: "http: " + e.status + " - " + e.body }); let error = I18n.t('admin.user.unsilence_failed', {
error: `http: ${e.status} - ${e.body}`
});
bootbox.alert(error); bootbox.alert(error);
}).finally(() => {
this.set('silencingUser', false);
}); });
}, },
silence() { silence(data) {
const user = this, this.set('silencingUser', true);
message = I18n.t("admin.user.silence_confirm"); return ajax(`/admin/users/${this.id}/silence`, {
type: 'PUT',
const performSilence = function() { data
user.set('silencingUser', true); }).then(result => {
return ajax('/admin/users/' + user.id + '/silence', { this.setProperties(result.silence);
type: 'PUT' }).catch(e => {
}).then(function() { let error = I18n.t('admin.user.silence_failed', {
window.location.reload(); error: `http: ${e.status} - ${e.body}`
}).catch(function(e) {
var error = I18n.t('admin.user.silence_failed', { error: "http: " + e.status + " - " + e.body });
bootbox.alert(error);
user.set('silencingUser', false);
}); });
}; bootbox.alert(error);
}).finally(() => {
const buttons = [{ this.set('silencingUser', false);
"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" });
}, },
sendActivationEmail() { sendActivationEmail() {
@ -475,17 +470,14 @@ const AdminUser = Discourse.User.extend({
} }
}.property('tl3_requirements'), }.property('tl3_requirements'),
suspendedBy: function() { @computed('suspended_by')
if (this.get('suspended_by')) { suspendedBy: wrapAdmin,
return AdminUser.create(this.get('suspended_by'));
}
}.property('suspended_by'),
approvedBy: function() { @computed('silenced_by')
if (this.get('approved_by')) { silencedBy: wrapAdmin,
return AdminUser.create(this.get('approved_by'));
} @computed('approved_by')
}.property('approved_by') approvedBy: wrapAdmin,
}); });

View File

@ -20,12 +20,12 @@ export default Ember.Service.extend({
}; };
}, },
showSuspendModal(user, opts) { _showControlModal(type, user, opts) {
opts = opts || {}; opts = opts || {};
let controller = showModal('admin-suspend-user', { let controller = showModal(`admin-${type}-user`, {
admin: true, admin: true,
modalClass: 'suspend-user-modal' modalClass: `${type}-user-modal`
}); });
if (opts.post) { if (opts.post) {
controller.set('post', 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) { _deleteSpammer(adminUser) {
return adminUser.checkEmail().then(() => { return adminUser.checkEmail().then(() => {

View File

@ -0,0 +1,50 @@
{{#d-modal-body title="admin.user.silence_modal_title"}}
{{#conditional-loading-spinner condition=loadingUser}}
<div class='until-controls'>
<label>
{{future-date-input
class="silence-until"
label="admin.user.silence_duration"
includeFarFuture=true
input=silenceUntil}}
</label>
</div>
<div class='reason-controls'>
<label>
<div class='silence-reason-label'>
{{{i18n 'admin.user.silence_reason_label'}}}
</div>
{{text-field
value=reason
class="silence-reason"
placeholderKey="admin.user.silence_reason_placeholder"}}
</label>
</div>
<label>
<div class='silence-message-label'>
{{i18n "admin.user.silence_message"}}
</div>
{{textarea
value=message
class="silence-message"
placeholder=(i18n "admin.user.silence_message_placeholder")}}
</label>
{{/conditional-loading-spinner}}
{{/d-modal-body}}
<div class="modal-footer">
{{d-button
class="btn-danger perform-silence"
action="silence"
disabled=submitDisabled
icon="microphone-slash"
label="admin.user.silence"}}
{{d-modal-cancel close=(action "closeModal")}}
{{conditional-loading-spinner condition=loading size="small"}}
</div>

View File

@ -349,19 +349,49 @@
<div class="display-row {{if model.silenced 'highlight-danger'}}"> <div class="display-row {{if model.silenced 'highlight-danger'}}">
<div class='field'>{{i18n 'admin.user.silenced'}}</div> <div class='field'>{{i18n 'admin.user.silenced'}}</div>
<div class='value'>{{i18n-yes-no model.silenced}}</div> <div class='value'>
{{i18n-yes-no model.silenced}}
{{#if model.isSilenced}}
{{#unless model.silencedForever}}
{{i18n "admin.user.suspended_until" until=model.silencedTillDate}}
{{/unless}}
{{/if}}
</div>
<div class='controls'> <div class='controls'>
{{#conditional-loading-spinner size="small" condition=model.silencingUser}} {{#conditional-loading-spinner size="small" condition=model.silencingUser}}
{{#if model.silenced}} {{#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'}} {{i18n 'admin.user.silence_explanation'}}
{{else}} {{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'}} {{i18n 'admin.user.silence_explanation'}}
{{/if}} {{/if}}
{{/conditional-loading-spinner}} {{/conditional-loading-spinner}}
</div> </div>
</div> </div>
{{#if model.isSilenced}}
<div class='display-row highlight-danger silence-info'>
<div class='field'>{{i18n 'admin.user.silenced_by'}}</div>
<div class='value'>
{{#link-to 'adminUser' silencedBy}}{{avatar model.silencedBy imageSize="tiny"}}{{/link-to}}
{{#link-to 'adminUser' silencedBy}}{{model.silencedBy.username}}{{/link-to}}
</div>
<div class='controls'>
<b>{{i18n 'admin.user.silence_reason'}}</b>:
{{model.silence_reason}}
</div>
</div>
{{/if}}
</section> </section>
{{#if currentUser.admin}} {{#if currentUser.admin}}

View File

@ -16,6 +16,8 @@ import PreloadStore from 'preload-store';
import { defaultHomepage } from 'discourse/lib/utilities'; import { defaultHomepage } from 'discourse/lib/utilities';
import { userPath } from 'discourse/lib/url'; import { userPath } from 'discourse/lib/url';
const isForever = dt => moment().diff(dt, 'years') < -500;
const User = RestModel.extend({ const User = RestModel.extend({
hasPMs: Em.computed.gt("private_messages_stats.all", 0), hasPMs: Em.computed.gt("private_messages_stats.all", 0),
@ -178,14 +180,16 @@ const User = RestModel.extend({
}, },
@computed("suspended_till") @computed("suspended_till")
suspendedForever(suspendedTill) { suspendedForever: isForever,
return moment().diff(suspendedTill, 'years') < -500;
}, @computed("silenced_till")
silencedForever: isForever,
@computed("suspended_till") @computed("suspended_till")
suspendedTillDate(suspendedTill) { suspendedTillDate: longDate,
return longDate(suspendedTill);
}, @computed("silenced_till")
silencedTillDate: longDate,
changeUsername(new_username) { changeUsername(new_username) {
return ajax(userPath(`${this.get('username_lower')}/preferences/username`), { return ajax(userPath(`${this.get('username_lower')}/preferences/username`), {

View File

@ -274,14 +274,48 @@ class Admin::UsersController < Admin::AdminController
def silence def silence
guardian.ensure_can_silence_user! @user 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 end
def unsilence def unsilence
guardian.ensure_can_unsilence_user! @user guardian.ensure_can_unsilence_user! @user
UserSilencer.unsilence(@user, current_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 end
def reject_bulk def reject_bulk

View File

@ -10,7 +10,7 @@ module Jobs
HEADER_ATTRS_FOR ||= HashWithIndifferentAccess.new( HEADER_ATTRS_FOR ||= HashWithIndifferentAccess.new(
user_archive: ['topic_title', 'category', 'sub_category', 'is_pm', 'post', 'like_count', 'reply_count', 'url', 'created_at'], 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_stats: ['topics_entered', 'posts_read_count', 'time_read', 'topic_count', 'post_count', 'likes_given', 'likes_received'],
user_profile: ['location', 'website', 'views'], user_profile: ['location', 'website', 'views'],
user_sso: ['external_id', 'external_email', 'external_username', 'external_name', 'external_avatar_url'], 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) def get_base_user_array(user)
user_array = [] 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 end
def add_single_sign_on(user, user_info_array) def add_single_sign_on(user, user_info_array)

View File

@ -22,7 +22,7 @@ module Jobs
ub.badge_id = #{Badge::Anniversary} AND ub.badge_id = #{Badge::Anniversary} AND
ub.granted_at BETWEEN '#{fmt_start_date}' AND '#{fmt_end_date}' ub.granted_at BETWEEN '#{fmt_start_date}' AND '#{fmt_end_date}'
WHERE u.active AND WHERE u.active AND
NOT u.silenced AND u.silenced_till IS NULL AND
NOT p.hidden AND NOT p.hidden AND
p.deleted_at IS NULL AND p.deleted_at IS NULL AND
t.visible AND t.visible AND

View File

@ -68,6 +68,21 @@ class UserNotifications < ActionMailer::Base
email_token: opts[:email_token]) email_token: opts[:email_token])
end 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) def account_suspended(user, opts = nil)
opts ||= {} opts ||= {}

View File

@ -82,7 +82,7 @@ class DirectoryItem < ActiveRecord::Base
LEFT OUTER JOIN posts AS p ON ua.target_post_id = p.id 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 LEFT OUTER JOIN categories AS c ON t.category_id = c.id
WHERE u.active WHERE u.active
AND NOT u.silenced AND u.silenced_till IS NULL
AND t.deleted_at IS NULL AND t.deleted_at IS NULL
AND COALESCE(t.visible, true) AND COALESCE(t.visible, true)
AND p.deleted_at IS NULL AND p.deleted_at IS NULL

View File

@ -147,8 +147,8 @@ class User < ActiveRecord::Base
# TODO-PERF: There is no indexes on any of these # TODO-PERF: There is no indexes on any of these
# and NotifyMailingListSubscribers does a select-all-and-loop # and NotifyMailingListSubscribers does a select-all-and-loop
# may want to create an index on (active, silence, suspended_till)? # may want to create an index on (active, silence, suspended_till)?
scope :silenced, -> { where(silenced: true) } scope :silenced, -> { where("silenced_till IS NOT NULL AND silenced_till > ?", Time.zone.now) }
scope :not_silenced, -> { where(silenced: false) } 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 :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 :not_suspended, -> { where('suspended_till IS NULL OR suspended_till <= ?', Time.zone.now) }
scope :activated, -> { where(active: true) } scope :activated, -> { where(active: true) }
@ -660,6 +660,22 @@ class User < ActiveRecord::Base
!!(suspended_till && suspended_till > DateTime.now) !!(suspended_till && suspended_till > DateTime.now)
end 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 def suspend_record
UserHistory.for(self, :suspend_user).order('id DESC').first UserHistory.for(self, :suspend_user).order('id DESC').first
end end

View File

@ -18,6 +18,7 @@ class AdminDetailedUserSerializer < AdminUserSerializer
:can_be_anonymized, :can_be_anonymized,
:suspend_reason, :suspend_reason,
:suspended_till, :suspended_till,
:silence_reason,
:primary_group_id, :primary_group_id,
:badge_count, :badge_count,
:warnings_received_count, :warnings_received_count,
@ -29,6 +30,7 @@ class AdminDetailedUserSerializer < AdminUserSerializer
has_one :approved_by, serializer: BasicUserSerializer, embed: :objects has_one :approved_by, serializer: BasicUserSerializer, embed: :objects
has_one :api_key, serializer: ApiKeySerializer, embed: :objects has_one :api_key, serializer: ApiKeySerializer, embed: :objects
has_one :suspended_by, serializer: BasicUserSerializer, 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_one :tl3_requirements, serializer: TrustLevel3RequirementsSerializer, embed: :objects
has_many :groups, embed: :object, serializer: BasicGroupSerializer has_many :groups, embed: :object, serializer: BasicGroupSerializer
@ -72,6 +74,14 @@ class AdminDetailedUserSerializer < AdminUserSerializer
object.suspend_record.try(:acting_user) object.suspend_record.try(:acting_user)
end end
def silence_reason
object.silence_reason
end
def silenced_by
object.silenced_record.try(:acting_user)
end
def include_tl3_requirements? def include_tl3_requirements?
object.has_trust_level?(TrustLevel[2]) object.has_trust_level?(TrustLevel[2])
end end

View File

@ -23,6 +23,7 @@ class AdminUserListSerializer < BasicUserSerializer
:suspended_till, :suspended_till,
:suspended, :suspended,
:silenced, :silenced,
:silenced_till,
:time_read, :time_read,
:staged :staged
@ -40,6 +41,22 @@ class AdminUserListSerializer < BasicUserSerializer
alias_method :include_associated_accounts?, :include_email? 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 def suspended
object.suspended? object.suspended?
end end

View File

@ -275,8 +275,13 @@ class StaffActionLogger
def log_silence_user(user, opts = {}) def log_silence_user(user, opts = {})
raise Discourse::InvalidParameters.new(:user) unless user raise Discourse::InvalidParameters.new(:user) unless user
UserHistory.create(params(opts).merge(action: UserHistory.actions[:silence_user], UserHistory.create(
target_user_id: user.id)) params(opts).merge(
action: UserHistory.actions[:silence_user],
target_user_id: user.id,
details: opts[:details]
)
)
end end
def log_unsilence_user(user, opts = {}) def log_unsilence_user(user, opts = {})

View File

@ -1,5 +1,7 @@
class UserSilencer class UserSilencer
attr_reader :user_history
def initialize(user, by_user = nil, opts = {}) def initialize(user, by_user = nil, opts = {})
@user, @by_user, @opts = user, by_user, opts @user, @by_user, @opts = user, by_user, opts
end end
@ -14,14 +16,26 @@ class UserSilencer
def silence def silence
hide_posts unless @opts[:keep_posts] hide_posts unless @opts[:keep_posts]
unless @user.silenced? unless @user.silenced_till.present?
@user.silenced = true @user.silenced_till = @opts[:silenced_till] || 1000.years.from_now
if @user.save if @user.save
message_type = @opts[:message] || :silenced_by_staff message_type = @opts[:message] || :silenced_by_staff
post = SystemMessage.create(@user, message_type)
if post && @by_user if @opts[:context].present?
StaffActionLogger.new(@by_user).log_silence_user(@user, context: "#{message_type}: '#{post.topic&.title rescue ''}' #{@opts[:reason]}") context = @opts[:context]
else
context = "#{message_type}: '#{post.topic&.title rescue ''}' #{@opts[:reason]}"
SystemMessage.create(@user, message_type)
end end
if @by_user
@user_history = StaffActionLogger.new(@by_user).log_silence_user(
@user,
context: context,
details: @opts[:reason]
)
end
return true
end end
else else
false false
@ -37,7 +51,7 @@ class UserSilencer
end end
def unsilence def unsilence
@user.silenced = false @user.silenced_till = nil
if @user.save if @user.save
SystemMessage.create(@user, :unsilenced) SystemMessage.create(@user, :unsilenced)
StaffActionLogger.new(@by_user).log_unsilence_user(@user) if @by_user StaffActionLogger.new(@by_user).log_unsilence_user(@user) if @by_user

View File

@ -3281,6 +3281,14 @@ en:
suspend_message: "Email Message" suspend_message: "Email Message"
suspend_message_placeholder: "Optionally, provide more information about the suspension and it will be emailed to the user." suspend_message_placeholder: "Optionally, provide more information about the suspension and it will be emailed to the user."
suspended_by: "Suspended by" 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})" suspended_until: "(until %{until})"
cant_suspend: "This user cannot be suspended." cant_suspend: "This user cannot be suspended."
delete_all_posts: "Delete all posts" delete_all_posts: "Delete all posts"

View File

@ -2660,6 +2660,17 @@ en:
%{message} %{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: account_exists:
title: "Account already exists" title: "Account already exists"
subject_template: "[%{email_prefix}] Account already exists" subject_template: "[%{email_prefix}] Account already exists"

View File

@ -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 # User for the smoke tests
if ENV["SMOKE"] == "1" if ENV["SMOKE"] == "1"
UserEmail.seed do |ue| UserEmail.seed do |ue|

View File

@ -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

View File

@ -141,10 +141,10 @@ SQL
SELECT invited_by_id SELECT invited_by_id
FROM invites i FROM invites i
JOIN users u2 ON u2.id = i.user_id 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 GROUP BY invited_by_id
HAVING COUNT(*) >= #{count.to_i} 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) ) (:backfill OR u.id IN (:user_ids) )
" "
end end

View File

@ -167,7 +167,7 @@ describe AdminUserIndexQuery do
describe "with a silenced user" 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 it "finds the silenced user" do
query = ::AdminUserIndexQuery.new(query: 'silenced') query = ::AdminUserIndexQuery.new(query: 'silenced')

View File

@ -57,7 +57,7 @@ describe Email::Receiver do
end end
it "raises a SilencedUserError when the sender has been silenced" do 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) expect { process(:silenced_sender) }.to raise_error(Email::Receiver::SilencedUserError)
end end

View File

@ -195,7 +195,7 @@ describe Guardian do
context "author is silenced" do context "author is silenced" do
before do before do
user.silenced = true user.silenced_till = 1.year.from_now
user.save user.save
end end
@ -853,13 +853,13 @@ describe Guardian do
end end
it "allows new posts from silenced users included in the pm" do 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) private_message.topic_allowed_users.create!(user_id: user.id)
expect(Guardian.new(user).can_create?(Post, private_message)).to be_truthy expect(Guardian.new(user).can_create?(Post, private_message)).to be_truthy
end end
it "doesn't allow new posts from silenced users not invited to the pm" do 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 expect(Guardian.new(user).can_create?(Post, private_message)).to be_falsey
end end
end end
@ -1376,7 +1376,7 @@ describe Guardian do
context 'when user is silenced' do context 'when user is silenced' do
it 'returns false' 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?(post)).to be(false)
expect(Guardian.new(user).can_moderate?(topic)).to be(false) expect(Guardian.new(user).can_moderate?(topic)).to be(false)
end end

View File

@ -495,15 +495,7 @@ describe Admin::UsersController do
end end
context '.destroy' do context '.destroy' do
before do let(:delete_me) { Fabricate(:user) }
@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
it "returns a 403 if the user doesn't exist" do it "returns a 403 if the user doesn't exist" do
delete :destroy, params: { id: 123123 }, format: :json delete :destroy, params: { id: 123123 }, format: :json
@ -511,31 +503,26 @@ describe Admin::UsersController do
end end
context "user has post" do context "user has post" do
let(:topic) { create_topic(user: delete_me) }
before do before do
@user = Fabricate(:user) _post = create_post(topic: topic, user: delete_me)
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)
end end
it "returns an error" do 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 expect(response).to be_forbidden
end end
it "doesn't return an error if delete_posts == true" do 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 expect(response).to be_success
end end
end end
it "deletes the user record" do it "deletes the user record" do
UserDestroyer.any_instance.expects(:destroy).returns(true) 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
end end
@ -590,9 +577,10 @@ describe Admin::UsersController do
it "raises an error when the user doesn't have permission" 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) 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 put :silence, params: { user_id: @reg_user.id }, format: :json
expect(response).to be_forbidden expect(response).to be_forbidden
@reg_user.reload
expect(@reg_user).not_to be_silenced
end end
it "returns a 403 if the user doesn't exist" do it "returns a 403 if the user doesn't exist" do
@ -601,8 +589,43 @@ describe Admin::UsersController do
end end
it "punishes the user for spamming" do 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 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
end end

View File

@ -710,7 +710,7 @@ describe PostsController do
expect(parsed["action"]).to eq("enqueued") expect(parsed["action"]).to eq("enqueued")
user.reload user.reload
expect(user.silenced).to eq(true) expect(user).to be_silenced
qp = QueuedPost.first qp = QueuedPost.first
@ -718,7 +718,7 @@ describe PostsController do
qp.approve!(mod) qp.approve!(mod)
user.reload user.reload
expect(user.silenced).to eq(false) expect(user).not_to be_silenced
end end
it "doesn't enqueue replies when the topic is closed" do it "doesn't enqueue replies when the topic is closed" do
@ -763,7 +763,7 @@ describe PostsController do
expect(parsed["action"]).to eq("enqueued") expect(parsed["action"]).to eq("enqueued")
user.reload user.reload
expect(user.silenced).to eq(true) expect(user).to be_silenced
end end
it "can send a message to a group" do it "can send a message to a group" do

View File

@ -24,7 +24,7 @@ describe Jobs::GrantAnniversaryBadges do
end end
it "doesn't award to a silenced user" do 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) Fabricate(:post, user: user, created_at: 1.week.ago)
granter.execute({}) granter.execute({})

View File

@ -66,7 +66,7 @@ describe Jobs::NotifyMailingListSubscribers do
end end
context "to a silenced user" do 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" include_examples "no emails"
end end

View File

@ -677,7 +677,7 @@ describe Topic do
context "when moderator post fails to be created" do context "when moderator post fails to be created" do
before do before do
user.toggle!(:silenced) user.update_column(:silenced_till, 1.year.from_now)
end end
it "should not increment moderator_posts_count" do 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' it_should_behave_like 'a status that closes a topic'
context 'topic was set to close when it was created' do 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)) freeze_time(Time.new(2000, 1, 1))
@topic.created_at = 3.days.ago @topic.created_at = 3.days.ago
@topic.update_status(status, true, @user) @topic.update_status(status, true, @user)
@ -842,7 +842,7 @@ describe Topic do
end end
context 'topic was set to close after it was created' do 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)) freeze_time(Time.new(2000, 1, 1))
@topic.created_at = 7.days.ago @topic.created_at = 7.days.ago

View File

@ -1532,4 +1532,32 @@ describe User do
]) ])
end end
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 end

View File

@ -275,7 +275,7 @@ describe SpamRule::AutoSilence do
end end
context "silenced, but has higher trust level now" do 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) } subject { described_class.new(user) }
it 'returns false' do it 'returns false' do

View File

@ -7,8 +7,8 @@ describe UserSilencer do
end end
describe 'silence' do describe 'silence' do
let(:user) { stub_everything(save: true) } let(:user) { Fabricate(:user) }
let(:silencer) { UserSilencer.new(user) } let(:silencer) { UserSilencer.new(user) }
subject(:silence_user) { silencer.silence } subject(:silence_user) { silencer.silence }
it 'silences the user' do it 'silences the user' do
@ -53,7 +53,7 @@ describe UserSilencer do
end end
it "doesn't send a pm if the user is already silenced" do 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.unstub(:create)
SystemMessage.expects(:create).never SystemMessage.expects(:create).never
expect(silence_user).to eq(false) expect(silence_user).to eq(false)
@ -73,7 +73,7 @@ describe UserSilencer do
subject(:unsilence_user) { UserSilencer.unsilence(user, Fabricate.build(:admin)) } subject(:unsilence_user) { UserSilencer.unsilence(user, Fabricate.build(:admin)) }
it 'unsilences the user' do 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? } expect { UserSilencer.unsilence(u) }.to change { u.reload.silenced? }
end end