0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 13:08:40 +08:00
discourse/lib/post_destroyer.rb
Régis Hanol c47c3eeba6
FIX: Enforce skip_review_media_groups when an edit adds media
The check only ran in `NewPostManager`, so it covered new posts and never
edits. A user could publish plain text, then edit an image in, and the post
would never reach the review queue. Inside `editing_grace_period` that edit
also creates no revision, so it left no trace in the UI either.

`PostRevisor` now runs the same check after a successful save, and queues a
`ReviewablePost` only when the edit introduces media the post did not already
have. Media is compared by its sources rather than its markup, so rescaling an
image or editing its alt text is not mistaken for new media, while replacing an
approved image with a different one is.

Undeleting your own post rewrites the original raw back in, which looks exactly
like adding media, so `PostDestroyer` now marks that revision as a recovery.

The `contains_media` reason also pointed moderators at a setting name that does
not exist, leaving them with a dead link from the review queue.
2026-08-04 17:01:44 +02:00

611 lines
19 KiB
Ruby
Vendored

# frozen_string_literal: true
#
# How a post is deleted is affected by who is performing the action.
# this class contains the logic to delete it.
#
class PostDestroyer
def self.destroy_old_hidden_posts
Post
.where(deleted_at: nil, hidden: true)
.where("hidden_at < ?", 30.days.ago)
.find_each do |post|
PostDestroyer.new(
Discourse.system_user,
post,
context: "Automatically destroyed hidden posts",
).destroy
end
end
def self.destroy_stubs
context = I18n.t("remove_posts_deleted_by_author")
# exclude deleted topics and posts that are actively flagged
Post
.where(deleted_at: nil, user_deleted: true)
.where(
"NOT EXISTS (
SELECT 1 FROM topics t
WHERE t.deleted_at IS NOT NULL AND
t.id = posts.topic_id
)",
)
.where("updated_at < ?", SiteSetting.delete_removed_posts_after.hours.ago)
.where(
"NOT EXISTS (
SELECT 1
FROM post_actions pa
WHERE pa.post_id = posts.id
AND pa.deleted_at IS NULL
AND pa.deferred_at IS NULL
AND pa.post_action_type_id IN (?)
)",
PostActionType.notify_flag_type_ids,
)
.find_each { |post| PostDestroyer.new(Discourse.system_user, post, context: context).destroy }
end
def self.delete_with_replies(performed_by, post, reviewable_id = nil, defer_reply_flags: true)
reply_ids = post.reply_ids(Guardian.new(performed_by), only_replies_to_single_post: false)
replies = Post.where(id: reply_ids.map { |r| r[:id] })
PostDestroyer.new(performed_by, post, reviewable_id: reviewable_id).destroy
options = { defer_flags: defer_reply_flags }
if SiteSetting.notify_users_after_responses_deleted_on_flagged_post
options.merge!({ reviewable_id: reviewable_id, notify_responders: true, parent_post: post })
end
replies.each { |reply| PostDestroyer.new(performed_by, reply, options).destroy }
end
def initialize(user, post, opts = {})
@user = user
@post = post
@topic = post.topic || Topic.with_deleted.find_by(id: @post.topic_id)
@opts = opts
if user == Discourse.system_user && opts[:context].blank?
Discourse.deprecate(<<~WARNING, drop_from: "3.6.0", output_in_test: true)
Using PostDestroyer as system user without providing a context will be an error in future versions.
WARNING
end
end
def destroy
delete_removed_posts_after =
@opts[:delete_removed_posts_after] || SiteSetting.delete_removed_posts_after
should_reset_bumped_at = @post.is_last_reply? && !@post.whisper?
if delete_removed_posts_after < 1 || post_is_reviewable? ||
Guardian.new(@user).can_moderate_topic?(@topic) || permanent?
perform_delete
elsif @user.id == @post.user_id
mark_for_deletion(delete_removed_posts_after)
end
resolve_reviewables_for_author_deletion if @user.id == @post.user_id
UserActionManager.post_destroyed(@post)
DiscourseEvent.trigger(:post_destroyed, @post, @opts, @user)
if WebHook.active_web_hooks(:post_destroyed).exists?
payload = WebHook.generate_payload(:post, @post)
WebHook.enqueue_post_hooks(:post_destroyed, @post, payload)
end
Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: @topic.id) if @topic
is_first_post = @post.is_first_post? && @topic
if is_first_post
UserProfile.remove_featured_topic_from_all_profiles(@topic)
UserActionManager.topic_destroyed(@topic)
DiscourseEvent.trigger(:topic_destroyed, @topic, @user)
if WebHook.active_web_hooks(:topic_destroyed).exists?
topic_view = TopicView.new(@topic, Discourse.system_user, skip_staff_action: true)
topic_payload = WebHook.generate_payload(:topic, topic_view, WebHookTopicViewSerializer)
WebHook.enqueue_topic_hooks(:topic_destroyed, @topic, topic_payload)
end
if SiteSetting.tos_topic_id == @topic.id || SiteSetting.privacy_topic_id == @topic.id
Discourse.clear_urls!
end
end
@topic.reset_bumped_at if should_reset_bumped_at
end
def recover
if (post_is_reviewable? || Guardian.new(@user).can_moderate_topic?(@post.topic)) &&
@post.deleted_at
staff_recovered
elsif @user.staff? || @user.id == @post.user_id
user_recovered
end
@topic.update_column(:user_id, Discourse::SYSTEM_USER_ID) if !@topic.user_id
@topic.recover!(@user) if @post.is_first_post?
@topic.update_statistics!
Topic.publish_stats_to_clients!(@topic.id, :recovered)
@topic.reset_bumped_at(@post) if @post.is_last_reply? && !@post.whisper?
UserActionManager.post_created(@post)
DiscourseEvent.trigger(:post_recovered, @post, @opts, @user)
Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: @topic.id) if @topic
Jobs.enqueue(:notify_mailing_list_subscribers, post_id: @post.id)
if @post.is_first_post?
UserActionManager.topic_created(@topic)
DiscourseEvent.trigger(:topic_recovered, @topic, @user)
if @user.id != @post.user_id
StaffActionLogger.new(@user).log_topic_delete_recover(
@topic,
"recover_topic",
@opts.slice(:context),
)
end
if SiteSetting.tos_topic_id == @topic.id || SiteSetting.privacy_topic_id == @topic.id
Discourse.clear_urls!
end
else
StaffActionLogger.new(@user).log_post_recover(@post) if @user.id != @post.user_id
end
end
def staff_recovered
new_post_attrs = { user_deleted: false }
new_post_attrs[:user_id] = Discourse::SYSTEM_USER_ID if !@post.user_id
@post.update_columns(new_post_attrs)
@post.recover!
mark_topic_changed
if @post.topic && !@post.topic.private_message?
if author = @post.user
if @post.is_first_post?
author.user_stat.topic_count += 1
else
author.user_stat.post_count += 1
end
author.user_stat.save!
end
if @post.is_first_post?
# Update stats of all people who replied
update_post_counts(:increment)
end
end
# skip also publishing topic stats because they weren't updated yet
@post.publish_change_to_clients! :recovered, { skip_topic_stats: true }
TopicTrackingState.publish_recover(@post.topic) if @post.topic && @post.is_first_post?
end
# When a post is properly deleted. Well, it's still soft deleted, but it will no longer
# show up in the topic
# Permanent option allows to hard delete.
def perform_delete
# All posts in the topic must be force deleted if the first is force
# deleted (except @post which is destroyed by current instance).
if @topic && @post.is_first_post? && permanent?
@topic.posts.with_deleted.find_each do |post|
PostDestroyer.new(@user, post, @opts).destroy if post.id != @post.id
end
end
Post.transaction do
permanent? ? @post.destroy! : @post.trash!(@user)
if @post.topic
make_previous_post_the_last_one
mark_topic_changed
clear_user_posted_flag
end
trash_public_post_actions
trash_revisions
trash_user_actions
remove_associated_replies
remove_associated_notifications
if @user.id != @post.user_id && !@opts[:skip_staff_log]
logger = StaffActionLogger.new(@user)
if @post.topic && @post.is_first_post?
logger.log_topic_delete_recover(
@post.topic,
permanent? ? "delete_topic_permanently" : "delete_topic",
@opts.slice(:context, :reviewable_id),
)
else
logger.log_post_deletion(
@post,
**@opts.slice(:context, :reviewable_id),
permanent: permanent?,
)
end
end
if @topic && @post.is_first_post?
permanent? ? @topic.destroy! : @topic.trash!(@user)
PublishedPage.unpublish!(@user, @topic) if @topic.published_page
end
TopicLink.where(link_post_id: @post.id).destroy_all
update_associated_category_latest_topic
update_user_counts if !permanent?
TopicUser.update_post_action_cache(post_id: @post.id)
if permanent?
if @post.topic && @post.is_first_post?
UserHistory.where(topic_id: @post.topic.id).update_all(details: "(permanently deleted)")
end
UserHistory.where(post_id: @post.id).update_all(details: "(permanently deleted)")
end
DB.after_commit do
Topic.reset_highest(@post.topic_id)
handle_reviewable_after_deletion
end
end
feature_users_in_the_topic if @post.topic
@post.publish_change_to_clients!(permanent? ? :destroyed : :deleted) if @post.topic
if @post.topic && @post.post_number == 1
TopicTrackingState.send(permanent? ? :publish_destroy : :publish_delete, @post.topic)
end
end
def permanent?
@opts[:force_destroy] ||
(@opts[:permanent] && @user == @post.user && @post.topic.private_message?)
end
# When a user 'deletes' their own post. We just change the text.
def mark_for_deletion(delete_removed_posts_after = SiteSetting.delete_removed_posts_after)
I18n.with_locale(SiteSetting.default_locale) do
# don't call revise from within transaction, high risk of deadlock
key =
(
if @post.is_first_post?
"js.topic.deleted_by_author_simple"
else
"js.post.deleted_by_author_simple"
end
)
@post.revise(
@user,
{ raw: I18n.t(key) },
force_new_version: true,
deleting_post: true,
skip_validations: true,
)
Post.transaction do
@post.update_column(:user_deleted, true)
@post.topic_links.each(&:destroy)
@post.topic.update_column(:closed, true) if @post.is_first_post?
end
end
end
def user_recovered
return unless @post.user_deleted?
Post.transaction do
@post.update_column(:user_deleted, false)
@post.skip_unique_check = true
@post.topic.update_column(:closed, false) if @post.is_first_post?
end
# has internal transactions, if we nest then there are some very high risk deadlocks
last_revision = @post.revisions.last
if last_revision.present? && last_revision.modifications["raw"].present?
@post.revise(
@user,
{ raw: last_revision.modifications["raw"][0] },
force_new_version: true,
recovering_post: true,
)
end
restore_reviewables_for_author_recovery if @user.id == @post.user_id
end
private
def post_is_reviewable?
return true if @user.staff?
Guardian.new(@user).can_review_topic?(@topic) && Reviewable.exists?(target: @post)
end
# we need topics to change if ever a post in them is deleted or created
# this ensures users relying on this information can keep unread tracking
# working as desired
def mark_topic_changed
# make this as fast as possible, can bypass everything
DB.exec(<<~SQL, updated_at: Time.now, id: @post.topic_id)
UPDATE topics
SET updated_at = :updated_at
WHERE id = :id
SQL
end
def make_previous_post_the_last_one
last_post =
Post
.select(:created_at, :user_id, :post_number)
.where("topic_id = ? and id <> ?", @post.topic_id, @post.id)
.where.not(user_id: nil)
.where.not(post_type: [Post.types[:whisper], Post.types[:small_action]])
.order("created_at desc")
.first
if last_post.present?
topic = @post.topic
topic.last_posted_at = last_post.created_at
topic.last_post_user_id = last_post.user_id
topic.highest_post_number = last_post.post_number
# we go via save here cause we need to run hooks
topic.save!(validate: false)
end
end
def clear_user_posted_flag
unless Post.exists?(
["topic_id = ? and user_id = ? and id <> ?", @post.topic_id, @post.user_id, @post.id],
)
TopicUser.where(topic_id: @post.topic_id, user_id: @post.user_id).update_all "posted = false"
end
end
def feature_users_in_the_topic
Jobs.enqueue(:feature_topic_users, topic_id: @post.topic_id)
end
def post_action_type_view
@post_action_type_view ||= PostActionTypeView.new
end
def trash_public_post_actions
if public_post_actions = PostAction.publics.where(post_id: @post.id)
public_post_actions.each { |pa| permanent? ? pa.destroy! : pa.trash!(@user) }
return if permanent?
@post.custom_fields["deleted_public_actions"] = public_post_actions.ids
@post.save_custom_fields
f = post_action_type_view.public_types.map { |k, _| ["#{k}_count", 0] }
Post.with_deleted.where(id: @post.id).update_all(Hash[*f.flatten])
end
end
def trash_revisions
return unless permanent?
@post.revisions.each(&:destroy!)
end
def agree(reviewable)
notify_deletion(reviewable)
result = reviewable.perform(@user, :agree_and_keep, post_was_deleted: true)
reviewable.transition_to(result.transition_to, @user)
end
def ignore(reviewable)
reviewable.perform_ignore_and_do_nothing(@user, post_was_deleted: true)
reviewable.transition_to(:ignored, @user)
end
def handle_reviewable_after_deletion
if @opts[:reviewable_id]
handle_explicit_reviewable
elsif @post.reviewable_flag
handle_post_reviewable_flag
end
end
def handle_explicit_reviewable
reviewable = Reviewable.find_by(id: @opts[:reviewable_id])
return unless reviewable
notify_deletion(
reviewable,
{ notify_responders: @opts[:notify_responders], parent_post: @opts[:parent_post] },
)
return unless @post.reviewable_flag
return unless SiteSetting.notify_users_after_responses_deleted_on_flagged_post
return if @post.reviewable_flag.potentially_illegal?
ignore(@post.reviewable_flag)
end
def handle_post_reviewable_flag
return ignore(@post.reviewable_flag) if @opts[:defer_flags]
return if @post.reviewable_flag.potentially_illegal?
agree(@post.reviewable_flag)
end
def notify_deletion(reviewable, options = {})
return if @post.user.blank?
allowed_user = @user.human? && @user.staff?
return unless allowed_user && rs = reviewable.reviewable_scores.order("created_at DESC").first
# ReviewableScore#types is a superset of PostActionType#flag_types.
# If the reviewable score type is not on the latter, it means it's not a flag by a user and
# must be an automated flag like `needs_approval`. There's no flag reason for these kind of types.
flag_type = post_action_type_view.flag_types[rs.reviewable_score_type]
return unless flag_type
notify_responders = options[:notify_responders]
Jobs.enqueue(
:send_system_message,
user_id: @post.user_id,
message_type:
(
if notify_responders
"flags_agreed_and_post_deleted_for_responders"
else
"flags_agreed_and_post_deleted"
end
),
message_options: {
flagged_post_raw_content: notify_responders ? options[:parent_post].raw : @post.raw,
flagged_post_response_raw_content: @post.raw,
url: notify_responders ? options[:parent_post].url : @post.url,
flag_reason:
I18n.t(
"flag_reasons#{".responder" if notify_responders}.#{flag_type}",
locale: SiteSetting.default_locale,
base_path: Discourse.base_path,
default: PostActionType.flags.find { |flag| flag[:name_key] == flag_type.to_s }[:name],
),
},
)
end
def trash_user_actions
UserAction
.where(target_post_id: @post.id)
.each do |ua|
row = {
action_type: ua.action_type,
user_id: ua.user_id,
acting_user_id: ua.acting_user_id,
target_topic_id: ua.target_topic_id,
target_post_id: ua.target_post_id,
}
UserAction.remove_action!(row)
end
end
def remove_associated_replies
post_ids = PostReply.where(reply_post_id: @post.id).pluck(:post_id)
if post_ids.present?
PostReply.where(reply_post_id: @post.id).delete_all
Post.where(id: post_ids).each { |p| p.update_column :reply_count, p.replies.count }
end
end
def remove_associated_notifications
Notification.where(topic_id: @post.topic_id, post_number: @post.post_number).delete_all
end
def update_associated_category_latest_topic
return unless @post.topic && @post.topic.category
if @post.id != @post.topic.category.latest_post_id &&
!(@post.is_first_post? && @post.topic_id == @post.topic.category.latest_topic_id)
return
end
@post.topic.category.update_latest
end
def update_user_counts
author = @post.user
return unless author
author.create_user_stat if author.user_stat.nil?
if @post.created_at == author.user_stat.first_post_created_at
author.user_stat.update!(
first_post_created_at: author.posts.order("created_at ASC").first.try(:created_at),
)
end
UserStatCountUpdater.decrement!(@post)
if @post.created_at == author.last_posted_at
author.update_column(
:last_posted_at,
author.posts.order("created_at DESC").first.try(:created_at),
)
end
if @post.is_first_post? && @post.topic && !@post.topic.private_message?
# Update stats of all people who replied
update_post_counts(:decrement)
end
end
def update_post_counts(operator)
counts =
Post
.where(post_type: Post.types[:regular], topic_id: @post.topic_id)
.where("post_number > 1")
.group(:user_id)
.count
counts.each do |user_id, count|
if user_stat = UserStat.where(user_id: user_id).first
if operator == :decrement
UserStatCountUpdater.set!(
user_stat: user_stat,
count: user_stat.post_count - count,
count_column: :post_count,
)
else
UserStatCountUpdater.set!(
user_stat: user_stat,
count: user_stat.post_count + count,
count_column: :post_count,
)
end
end
end
end
def resolve_reviewables_for_author_deletion
reviewables = Reviewable.where(target: @post, status: Reviewable.statuses[:pending])
if user_penalized_for_post?
reviewables.find_each do |reviewable|
note = I18n.t("reviewables.post_deleted_by_author_after_penalty")
next if reviewable.reviewable_notes.exists?(user: Discourse.system_user, content: note)
reviewable.reviewable_notes.create!(user: Discourse.system_user, content: note)
end
return
end
reviewables.find_each { |reviewable| reviewable.transition_to(:ignored, Discourse.system_user) }
end
def user_penalized_for_post?
return false unless @post.user.silenced? || @post.user.suspended?
UserHistory.exists?(
action: [UserHistory.actions[:silence_user], UserHistory.actions[:suspend_user]],
post: @post,
)
end
def restore_reviewables_for_author_recovery
# Only restore if it was reviewed by system user
reviewables =
Reviewable
.where(target: @post, status: Reviewable.statuses[:ignored])
.joins(
"LEFT JOIN reviewable_scores ON reviewable_scores.reviewable_id = reviewables.id AND reviewable_scores.reviewed_by_id = #{Discourse::SYSTEM_USER_ID}",
)
.where("reviewable_scores.id IS NOT NULL")
reviewables.each do |reviewable|
reviewable.reviewable_notes.create!(
user: Discourse.system_user,
content: I18n.t("reviewables.post_restored_by_author"),
)
reviewable.transition_to(:pending, Discourse.system_user)
end
end
end