discourse/app/models/post_action.rb
Robin Ward bc3efab816 FIX: When disagreeing with a flag that silenced a user, unsilence them
Previously it would unhide their post but leave them silenced.

This fix also cleans up some of the helper classes to make it easier
to pass extra data to the silencing code (for example, a link to the
post that caused the user to be silenced.)

This patch also refactors the auto_silence specs to avoid using
stubs.
2019-02-08 08:50:50 -05:00

723 lines
24 KiB
Ruby

require_dependency 'rate_limiter'
require_dependency 'system_message'
class PostAction < ActiveRecord::Base
class AlreadyActed < StandardError; end
class FailedToCreatePost < StandardError; end
include RateLimiter::OnCreateRecord
include Trashable
belongs_to :post
belongs_to :user
belongs_to :post_action_type
belongs_to :related_post, class_name: 'Post'
belongs_to :target_user, class_name: 'User'
rate_limit :post_action_rate_limiter
scope :spam_flags, -> { where(post_action_type_id: PostActionType.types[:spam]) }
scope :flags, -> { where(post_action_type_id: PostActionType.notify_flag_type_ids) }
scope :publics, -> { where(post_action_type_id: PostActionType.public_type_ids) }
scope :active, -> { where(disagreed_at: nil, deferred_at: nil, agreed_at: nil, deleted_at: nil) }
after_save :update_counters
after_save :enforce_rules
after_save :create_user_action
after_save :update_notifications
after_create :create_notifications
after_commit :notify_subscribers
def disposed_by_id
disagreed_by_id || agreed_by_id || deferred_by_id
end
def disposed_at
disagreed_at || agreed_at || deferred_at
end
def disposition
return :disagreed if disagreed_at
return :agreed if agreed_at
return :deferred if deferred_at
nil
end
def self.flag_count_by_date(start_date, end_date, category_id = nil)
result = where('post_actions.created_at >= ? AND post_actions.created_at <= ?', start_date, end_date)
result = result.where(post_action_type_id: PostActionType.flag_types_without_custom.values)
result = result.joins(post: :topic).where("topics.category_id = ?", category_id) if category_id
result.group('date(post_actions.created_at)')
.order('date(post_actions.created_at)')
.count
end
# Forums can choose to apply a minimum number of flags required before it shows up in
# the admin interface. One exception is posts hidden by tl3/tl4 - we want those to
# show up even if the minimum visibility is not met.
def self.apply_minimum_visibility(relation)
return relation unless SiteSetting.min_flags_staff_visibility > 1
params = {
min_flags: SiteSetting.min_flags_staff_visibility,
hidden_reasons: Post.hidden_reasons.only(:flagged_by_tl3_user, :flagged_by_tl4_user).values
}
relation.having(<<~SQL, params)
(COUNT(*) >= :min_flags) OR
(SUM(CASE
WHEN posts.hidden_reason_id IN (:hidden_reasons) THEN 1
ELSE 0
END) > 0)
SQL
end
def self.update_flagged_posts_count
flagged_relation = PostAction.active
.flags
.joins(post: :topic)
.where('posts.deleted_at' => nil)
.where('topics.deleted_at' => nil)
.where('posts.user_id > 0')
.group("posts.id")
flagged_relation = apply_minimum_visibility(flagged_relation)
posts_flagged_count = flagged_relation
.pluck("posts.id")
.count
$redis.set('posts_flagged_count', posts_flagged_count)
user_ids = User.staff.pluck(:id)
MessageBus.publish('/flagged_counts', { total: posts_flagged_count }, user_ids: user_ids)
end
def self.flagged_posts_count
$redis.get('posts_flagged_count').to_i
end
def self.counts_for(collection, user)
return {} if collection.blank? || !user
collection_ids = collection.map(&:id)
user_id = user.try(:id) || 0
post_actions = PostAction.where(post_id: collection_ids, user_id: user_id)
user_actions = {}
post_actions.each do |post_action|
user_actions[post_action.post_id] ||= {}
user_actions[post_action.post_id][post_action.post_action_type_id] = post_action
end
user_actions
end
def self.lookup_for(user, topics, post_action_type_id)
return if topics.blank?
# in critical path 2x faster than AR
#
topic_ids = topics.map(&:id)
map = {}
builder = DB.build <<~SQL
SELECT p.topic_id, p.post_number
FROM post_actions pa
JOIN posts p ON pa.post_id = p.id
WHERE p.deleted_at IS NULL AND pa.deleted_at IS NULL AND
pa.post_action_type_id = :post_action_type_id AND
pa.user_id = :user_id AND
p.topic_id IN (:topic_ids)
ORDER BY p.topic_id, p.post_number
SQL
builder.query(user_id: user.id, post_action_type_id: post_action_type_id, topic_ids: topic_ids).each do |row|
(map[row.topic_id] ||= []) << row.post_number
end
map
end
def self.active_flags_counts_for(collection)
return {} if collection.blank?
collection_ids = collection.map(&:id)
post_actions = PostAction.active.flags.where(post_id: collection_ids)
user_actions = {}
post_actions.each do |post_action|
user_actions[post_action.post_id] ||= {}
user_actions[post_action.post_id][post_action.post_action_type_id] ||= []
user_actions[post_action.post_id][post_action.post_action_type_id] << post_action
end
user_actions
end
def self.count_per_day_for_type(post_action_type, opts = nil)
opts ||= {}
result = unscoped.where(post_action_type_id: post_action_type)
result = result.where('post_actions.created_at >= ?', opts[:start_date] || (opts[:since_days_ago] || 30).days.ago)
result = result.where('post_actions.created_at <= ?', opts[:end_date]) if opts[:end_date]
result = result.joins(post: :topic).merge(Topic.in_category_and_subcategories(opts[:category_id])) if opts[:category_id]
result.group('date(post_actions.created_at)')
.order('date(post_actions.created_at)')
.count
end
def self.agree_flags!(post, moderator, delete_post = false)
actions = PostAction.active
.where(post_id: post.id)
.where(post_action_type_id: PostActionType.notify_flag_types.values)
trigger_spam = false
actions.each do |action|
action.agreed_at = Time.zone.now
action.agreed_by_id = moderator.id
# so callback is called
action.save
action.add_moderator_post_if_needed(moderator, :agreed, delete_post)
trigger_spam = true if action.post_action_type_id == PostActionType.types[:spam]
end
# Update the flags_agreed user stat
UserStat.where(user_id: actions.map(&:user_id)).update_all("flags_agreed = flags_agreed + 1")
DiscourseEvent.trigger(:confirmed_spam_post, post) if trigger_spam
if actions.first.present?
DiscourseEvent.trigger(:flag_reviewed, post)
DiscourseEvent.trigger(:flag_agreed, actions.first)
end
update_flagged_posts_count
end
def self.clear_flags!(post, moderator)
# -1 is the automatic system cleary
action_type_ids =
if moderator.id == Discourse::SYSTEM_USER_ID
PostActionType.auto_action_flag_types.values
else
PostActionType.notify_flag_type_ids
end
actions = PostAction.active.where(post_id: post.id).where(post_action_type_id: action_type_ids)
actions.each do |action|
action.disagreed_at = Time.zone.now
action.disagreed_by_id = moderator.id
# so callback is called
action.save
action.add_moderator_post_if_needed(moderator, :disagreed)
end
# Update the flags_disagreed user stat
UserStat.where(user_id: actions.map(&:user_id)).update_all("flags_disagreed = flags_disagreed + 1")
# reset all cached counters
cached = {}
action_type_ids.each do |atid|
column = "#{PostActionType.types[atid]}_count"
cached[column] = 0 if ActiveRecord::Base.connection.column_exists?(:posts, column)
end
Post.with_deleted.where(id: post.id).update_all(cached)
if actions.first.present?
DiscourseEvent.trigger(:flag_reviewed, post)
DiscourseEvent.trigger(:flag_disagreed, actions.first)
end
update_flagged_posts_count
undo_hide_and_silence(post)
end
def self.defer_flags!(post, moderator, delete_post = false)
actions = PostAction.active
.where(post_id: post.id)
.where(post_action_type_id: PostActionType.notify_flag_type_ids)
actions.each do |action|
action.deferred_at = Time.zone.now
action.deferred_by_id = moderator.id
# so callback is called
action.save
action.add_moderator_post_if_needed(moderator, :deferred, delete_post)
end
if actions.first.present?
DiscourseEvent.trigger(:flag_reviewed, post)
DiscourseEvent.trigger(:flag_deferred, actions.first)
end
update_flagged_posts_count
end
def add_moderator_post_if_needed(moderator, disposition, delete_post = false)
return if !SiteSetting.auto_respond_to_flag_actions
return if related_post.nil? || related_post.topic.nil?
return if staff_already_replied?(related_post.topic)
message_key = "flags_dispositions.#{disposition}"
message_key << "_and_deleted" if delete_post
I18n.with_locale(SiteSetting.default_locale) do
related_post.topic.add_moderator_post(moderator, I18n.t(message_key))
end
end
def staff_already_replied?(topic)
topic.posts.where("user_id IN (SELECT id FROM users WHERE moderator OR admin) OR (post_type != :regular_post_type)", regular_post_type: Post.types[:regular]).exists?
end
def self.create_message_for_post_action(user, post, post_action_type_id, opts)
post_action_type = PostActionType.types[post_action_type_id]
return unless opts[:message] && [:notify_moderators, :notify_user, :spam].include?(post_action_type)
title = I18n.t("post_action_types.#{post_action_type}.email_title", title: post.topic.title, locale: SiteSetting.default_locale)
body = I18n.t("post_action_types.#{post_action_type}.email_body", message: opts[:message], link: "#{Discourse.base_url}#{post.url}", locale: SiteSetting.default_locale)
warning = opts[:is_warning] if opts[:is_warning].present?
title = title.truncate(SiteSetting.max_topic_title_length, separator: /\s/)
opts = {
archetype: Archetype.private_message,
is_warning: warning,
title: title,
raw: body
}
if [:notify_moderators, :spam].include?(post_action_type)
opts[:subtype] = TopicSubtype.notify_moderators
opts[:target_group_names] = target_moderators
else
opts[:subtype] = TopicSubtype.notify_user
opts[:target_usernames] =
if post_action_type == :notify_user
post.user.username
elsif post_action_type != :notify_moderators
# this is a hack to allow a PM with no recipients, we should think through
# a cleaner technique, a PM with myself is valid for flagging
'x'
end
end
PostCreator.new(user, opts).create!&.id
end
def self.limit_action!(user, post, post_action_type_id)
RateLimiter.new(user, "post_action-#{post.id}_#{post_action_type_id}", 4, 1.minute).performed!
end
def self.act(user, post, post_action_type_id, opts = {})
limit_action!(user, post, post_action_type_id)
begin
related_post_id = create_message_for_post_action(user, post, post_action_type_id, opts)
rescue ActiveRecord::RecordNotSaved => e
raise FailedToCreatePost.new(e.message)
end
staff_took_action = opts[:take_action] || false
targets_topic =
if opts[:flag_topic] && post.topic
post.topic.reload.posts_count != 1
end
where_attrs = {
post_id: post.id,
user_id: user.id,
post_action_type_id: post_action_type_id
}
action_attrs = {
staff_took_action: staff_took_action,
related_post_id: related_post_id,
targets_topic: !!targets_topic
}
# First try to revive a trashed record
post_action = PostAction.where(where_attrs)
.with_deleted
.where("deleted_at IS NOT NULL")
.first
if post_action
post_action.recover!
action_attrs.each { |attr, val| post_action.send("#{attr}=", val) }
post_action.save
PostActionNotifier.post_action_created(post_action)
else
post_action = create(where_attrs.merge(action_attrs))
if post_action && post_action.errors.count == 0
BadgeGranter.queue_badge_grant(Badge::Trigger::PostAction, post_action: post_action)
end
end
if post_action && PostActionType.notify_flag_type_ids.include?(post_action_type_id)
DiscourseEvent.trigger(:flag_created, post_action)
end
GivenDailyLike.increment_for(user.id) if post_action_type_id == PostActionType.types[:like]
# agree with other flags
if staff_took_action
PostAction.agree_flags!(post, user)
post_action.try(:update_counters)
end
post_action
rescue ActiveRecord::RecordNotUnique
# can happen despite being .create
# since already bookmarked
PostAction.where(where_attrs).first
end
def self.undo_hide_and_silence(post)
return unless post.hidden?
post.unhide!
UserSilencer.unsilence(post.user) if UserSilencer.was_silenced_for?(post)
end
def self.copy(original_post, target_post)
cols_to_copy = (column_names - %w{id post_id}).join(', ')
DB.exec <<~SQL
INSERT INTO post_actions(post_id, #{cols_to_copy})
SELECT #{target_post.id}, #{cols_to_copy}
FROM post_actions
WHERE post_id = #{original_post.id}
SQL
target_post.post_actions.each { |post_action| post_action.update_counters }
end
def self.remove_act(user, post, post_action_type_id)
limit_action!(user, post, post_action_type_id)
finder = PostAction.where(post_id: post.id, user_id: user.id, post_action_type_id: post_action_type_id)
finder = finder.with_deleted.includes(:post) if user.try(:staff?)
if action = finder.first
action.remove_act!(user)
action.post.unhide! if action.staff_took_action
GivenDailyLike.decrement_for(user.id) if post_action_type_id == PostActionType.types[:like]
end
end
def remove_act!(user)
trash!(user)
# NOTE: save is called to ensure all callbacks are called
# trash will not trigger callbacks, and triggering after_commit
# is not trivial
save
end
def is_bookmark?
post_action_type_id == PostActionType.types[:bookmark]
end
def is_like?
post_action_type_id == PostActionType.types[:like]
end
def is_flag?
!!PostActionType.notify_flag_types[post_action_type_id]
end
def is_private_message?
post_action_type_id == PostActionType.types[:notify_user] ||
post_action_type_id == PostActionType.types[:notify_moderators]
end
# A custom rate limiter for this model
def post_action_rate_limiter
return unless is_flag? || is_bookmark? || is_like?
return @rate_limiter if @rate_limiter.present?
%w(like flag bookmark).each do |type|
if send("is_#{type}?")
limit = SiteSetting.send("max_#{type}s_per_day")
if is_like? && user && user.trust_level >= 2
multiplier = SiteSetting.send("tl#{user.trust_level}_additional_likes_per_day_multiplier").to_f
multiplier = 1.0 if multiplier < 1.0
limit = (limit * multiplier).to_i
end
@rate_limiter = RateLimiter.new(user, "create_#{type}", limit, 1.day.to_i)
return @rate_limiter
end
end
end
before_create do
post_action_type_ids = is_flag? ? PostActionType.notify_flag_types.values : post_action_type_id
raise AlreadyActed if PostAction.where(user_id: user_id)
.where(post_id: post_id)
.where(post_action_type_id: post_action_type_ids)
.where(deleted_at: nil)
.where(disagreed_at: nil)
.where(targets_topic: targets_topic)
.exists?
end
# Returns the flag counts for a post, taking into account that some users
# can weigh flags differently.
def self.flag_counts_for(post_id)
params = {
post_id: post_id,
post_action_types: PostActionType.auto_action_flag_types.values,
flags_required_to_hide_post: SiteSetting.flags_required_to_hide_post
}
DB.query_single(<<~SQL, params)
SELECT COALESCE(SUM(CASE
WHEN pa.disagreed_at IS NOT NULL AND pa.staff_took_action THEN :flags_required_to_hide_post
WHEN pa.disagreed_at IS NOT NULL AND NOT pa.staff_took_action THEN 1
ELSE 0
END),0) AS old_flags,
COALESCE(SUM(CASE
WHEN pa.disagreed_at IS NULL AND pa.staff_took_action THEN :flags_required_to_hide_post
WHEN pa.disagreed_at IS NULL AND NOT pa.staff_took_action THEN 1
ELSE 0
END), 0) AS new_flags
FROM post_actions AS pa
INNER JOIN users AS u ON u.id = pa.user_id
WHERE pa.post_id = :post_id
AND pa.post_action_type_id in (:post_action_types)
AND pa.deleted_at IS NULL
SQL
end
def post_action_type_key
PostActionType.types[post_action_type_id]
end
def update_counters
# Update denormalized counts
column = "#{post_action_type_key}_count"
count = PostAction.where(post_id: post_id)
.where(post_action_type_id: post_action_type_id)
.count
# We probably want to refactor this method to something cleaner.
case post_action_type_key
when :like
# 'like_score' is weighted higher for staff accounts
score = PostAction.joins(:user)
.where(post_id: post_id)
.sum("CASE WHEN users.moderator OR users.admin THEN #{SiteSetting.staff_like_weight} ELSE 1 END")
Post.where(id: post_id).update_all ["like_count = :count, like_score = :score", count: count, score: score]
else
if ActiveRecord::Base.connection.column_exists?(:posts, column)
Post.where(id: post_id).update_all ["#{column} = ?", count]
end
end
topic_id = Post.with_deleted.where(id: post_id).pluck(:topic_id).first
# topic_user
if [:like, :bookmark].include? post_action_type_key
TopicUser.update_post_action_cache(user_id: user_id,
topic_id: topic_id,
post_action_type: post_action_type_key)
end
if column == "like_count"
topic_count = Post.where(topic_id: topic_id).sum(column)
Topic.where(id: topic_id).update_all ["#{column} = ?", topic_count]
end
if PostActionType.notify_flag_type_ids.include?(post_action_type_id)
PostAction.update_flagged_posts_count
end
end
def enforce_rules
post = Post.with_deleted.where(id: post_id).first
PostAction.auto_close_if_threshold_reached(post.topic)
PostAction.auto_hide_if_needed(user, post, post_action_type_key)
SpamRule::AutoSilence.new(post.user, post).perform
end
def create_user_action
if is_bookmark? || is_like?
UserActionCreator.log_post_action(self)
end
end
def update_notifications
if self.deleted_at.present?
PostActionNotifier.post_action_deleted(self)
end
end
def create_notifications
PostActionNotifier.post_action_created(self)
end
def notify_subscribers
if (is_like? || is_flag?) && post
post.publish_change_to_clients! :acted
end
end
MAXIMUM_FLAGS_PER_POST = 3
def self.auto_close_threshold_reached?(topic)
return if topic.user&.staff?
flags = PostAction.active
.flags
.joins(:post)
.where("posts.topic_id = ?", topic.id)
.where("post_actions.user_id > 0")
.group("post_actions.user_id")
.pluck("post_actions.user_id, COUNT(post_id)")
# we need a minimum number of unique flaggers
return if flags.count < SiteSetting.num_flaggers_to_close_topic
# we need a minimum number of flags
return if flags.sum { |f| f[1] } < SiteSetting.num_flags_to_close_topic
true
end
def self.auto_close_if_threshold_reached(topic)
return if topic.nil? || topic.closed?
return unless auto_close_threshold_reached?(topic)
# the threshold has been reached, we will close the topic waiting for intervention
topic.update_status("closed", true, Discourse.system_user,
message: I18n.t(
"temporarily_closed_due_to_flags",
count: SiteSetting.num_hours_to_close_topic
)
)
topic.set_or_create_timer(
TopicTimer.types[:open],
SiteSetting.num_hours_to_close_topic,
by_user: Discourse.system_user
)
end
def self.auto_hide_if_needed(acting_user, post, post_action_type)
return if post.hidden?
return if (!acting_user.staff?) && post.user&.staff?
if post_action_type == :spam &&
acting_user.has_trust_level?(TrustLevel[3]) &&
post.user&.trust_level == TrustLevel[0]
hide_post!(post, post_action_type, Post.hidden_reasons[:flagged_by_tl3_user])
elsif PostActionType.auto_action_flag_types.include?(post_action_type)
if acting_user.has_trust_level?(TrustLevel[4]) &&
!acting_user.staff? &&
post.user&.trust_level != TrustLevel[4]
hide_post!(post, post_action_type, Post.hidden_reasons[:flagged_by_tl4_user])
elsif SiteSetting.flags_required_to_hide_post > 0
_old_flags, new_flags = PostAction.flag_counts_for(post.id)
if new_flags >= SiteSetting.flags_required_to_hide_post
hide_post!(post, post_action_type, guess_hide_reason(post))
end
end
end
end
def self.hide_post!(post, post_action_type, reason = nil)
return if post.hidden
unless reason
reason = guess_hide_reason(post)
end
hiding_again = post.hidden_at.present?
post.hidden = true
post.hidden_at = Time.zone.now
post.hidden_reason_id = reason
post.save
Topic.where("id = :topic_id AND NOT EXISTS(SELECT 1 FROM POSTS WHERE topic_id = :topic_id AND NOT hidden)", topic_id: post.topic_id).update_all(visible: false)
# inform user
if post.user
options = {
url: post.url,
edit_delay: SiteSetting.cooldown_minutes_after_hiding_posts,
flag_reason: I18n.t(
"flag_reasons.#{post_action_type}",
locale: SiteSetting.default_locale,
base_path: Discourse.base_path
)
}
Jobs.enqueue_in(5.seconds, :send_system_message,
user_id: post.user.id,
message_type: hiding_again ? :post_hidden_again : :post_hidden,
message_options: options)
end
update_flagged_posts_count
end
def self.guess_hide_reason(post)
post.hidden_at ?
Post.hidden_reasons[:flag_threshold_reached_again] :
Post.hidden_reasons[:flag_threshold_reached]
end
def self.post_action_type_for_post(post_id)
post_action = PostAction.find_by(deferred_at: nil, post_id: post_id, post_action_type_id: PostActionType.notify_flag_types.values, deleted_at: nil)
PostActionType.types[post_action.post_action_type_id] if post_action
end
def self.target_moderators
Group[:moderators].name
end
end
# == Schema Information
#
# Table name: post_actions
#
# id :integer not null, primary key
# post_id :integer not null
# user_id :integer not null
# post_action_type_id :integer not null
# deleted_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
# deleted_by_id :integer
# related_post_id :integer
# staff_took_action :boolean default(FALSE), not null
# deferred_by_id :integer
# targets_topic :boolean default(FALSE), not null
# agreed_at :datetime
# agreed_by_id :integer
# deferred_at :datetime
# disagreed_at :datetime
# disagreed_by_id :integer
#
# Indexes
#
# idx_unique_actions (user_id,post_action_type_id,post_id,targets_topic) UNIQUE WHERE ((deleted_at IS NULL) AND (disagreed_at IS NULL) AND (deferred_at IS NULL))
# idx_unique_flags (user_id,post_id,targets_topic) UNIQUE WHERE ((deleted_at IS NULL) AND (disagreed_at IS NULL) AND (deferred_at IS NULL) AND (post_action_type_id = ANY (ARRAY[3, 4, 7, 8])))
# index_post_actions_on_post_id (post_id)
# index_post_actions_on_user_id_and_post_action_type_id (user_id,post_action_type_id) WHERE (deleted_at IS NULL)
#