DEV: Add rake task to bulk delete posts (#31576)

This PR adds a destroy:posts rake task that can be used to hard-delete a list of posts. Useful for dealing with large amounts of spam that has been soft deleted and needs to go.

Notes:

Works on both non-deleted and soft-deleted posts. (We might want to change this to work on only soft-deleted posts?)
Works exclusively on post IDs. We can't mix topic and post IDs as they might clash, and we have no way of resolving that ambiguity.
Accepts either a rake-style array of IDs or, more conveniently, you can pipe the argument in through STDIN.
Added a confirmation step since it's a fairly destructive operation.
This commit is contained in:
Ted Johansson
2025-03-04 17:29:38 +08:00
committed by GitHub
parent 06a0108a52
commit 42c4427cb1
6 changed files with 113 additions and 0 deletions

View File

@ -1,5 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require "highline/import"
class DestroyTask class DestroyTask
def initialize(io = STDOUT) def initialize(io = STDOUT)
@io = io @io = io
@ -46,6 +48,32 @@ class DestroyTask
categories.each { |c| @io.puts destroy_topics(c.slug, c.parent_category&.slug) } categories.each { |c| @io.puts destroy_topics(c.slug, c.parent_category&.slug) }
end end
def destroy_posts(post_ids, require_confirmation: true)
posts = Post.with_deleted.where(id: post_ids)
@io.puts "There are #{posts.count} posts to delete"
if posts.count < post_ids.size
@io.puts "Couldn't find the following posts:"
@io.puts " #{post_ids.map(&:to_i) - posts.pluck(:id)}"
end
if require_confirmation
confirm_destroy = ask("Are you sure? (Y/n)")
exit 1 if confirm_destroy.downcase != "y"
end
posts.find_each do |post|
@io.puts "Destroying post #{post.id}"
@io.puts PostDestroyer.new(
Discourse.system_user,
post,
context: I18n.t("staff_action_logs.cli_bulk_post_delete"),
force_destroy: true,
).destroy
end
end
def destroy_private_messages def destroy_private_messages
Topic Topic
.where(archetype: "private_message") .where(archetype: "private_message")

View File

@ -5560,6 +5560,7 @@ en:
revoked: Revoked revoked: Revoked
restored: Restored restored: Restored
bulk_user_delete: "deleted in a bulk delete operation" bulk_user_delete: "deleted in a bulk delete operation"
cli_bulk_post_delete: "Bulk deleted from rake task"
reviewables: reviewables:
already_handled: "Thanks, but we've already reviewed that post and determined it does not need to be flagged again." already_handled: "Thanks, but we've already reviewed that post and determined it does not need to be flagged again."

View File

@ -50,3 +50,16 @@ task "destroy:categories" => :environment do |t, args|
puts "Going to delete these categories: #{categories}" puts "Going to delete these categories: #{categories}"
categories.each { |id| destroy_task.destroy_category(id, true) } categories.each { |id| destroy_task.destroy_category(id, true) }
end end
# Hard delete a list of posts by ID. Pass a comma
# separated list either as a rake argument or through
# STDIN, e.g. through a pipe.
#
# Example: rake destroy:posts[4,8,15,16,23,42]
# Example: cat post_ids.txt | rake destroy:posts
desc "Destroy a list of posts given by ID"
task "destroy:posts" => :environment do |_task, args|
post_ids = args.extras.empty? ? STDIN.read.strip.split(",") : args.extras
puts "Going to delete these posts: #{post_ids}"
DestroyTask.new.destroy_posts(post_ids)
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
describe "destroy:posts" do # rubocop:disable RSpec/DescribeClass
subject(:task) { subject }
include_context "in a rake task"
# No console output in test suite, thanks.
before { STDOUT.stubs(:puts) }
it "accepts a list of post IDs piped through STDIN" do
destroy_task = instance_spy(DestroyTask)
DestroyTask.stubs(:new).returns(destroy_task)
STDIN.stubs(:read).returns("1,2,3\n")
task.invoke
expect(destroy_task).to have_received(:destroy_posts).with(%w[1 2 3])
end
end

View File

@ -67,6 +67,30 @@ RSpec.describe DestroyTask do
end end
end end
describe "#destroy_posts" do
let(:task) { DestroyTask.new(StringIO.new) }
let!(:t1) { Fabricate(:topic) }
let!(:t2) { Fabricate(:topic) }
let!(:p1) { Fabricate(:post, topic: t1) }
let!(:p2) { Fabricate(:post, topic: t1) }
let!(:p3) { Fabricate(:post, topic: t2) }
before { p2.trash! }
it "destroys posts listed and creates staff action logs" do
expect { task.destroy_posts([p2.id, p3.id], require_confirmation: false) }.to change {
Post.with_deleted.count
}.by(-2).and change { UserHistory.pluck(:action) }.from([]).to(
[
UserHistory.actions[:delete_post_permanently],
UserHistory.actions[:delete_topic_permanently],
],
)
end
end
describe "private messages" do describe "private messages" do
let!(:pm) { Fabricate(:private_message_post) } let!(:pm) { Fabricate(:private_message_post) }
let!(:pm2) { Fabricate(:private_message_post) } let!(:pm2) { Fabricate(:private_message_post) }

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
require "rake"
shared_context "in a rake task" do
subject { rake[task_name] }
let(:rake) { Rake::Application.new }
let(:task_name) { self.class.top_level_description }
let(:task_path) { "lib/tasks/#{task_name.split(":").first}" }
def loaded_files_excluding_current_rake_file
$".reject { |file| file == Rails.root.join("#{task_path}.rake").to_s }
end
before do
Rake.application = rake
Rake.application.rake_require(
task_path,
[Rails.root.to_s],
loaded_files_excluding_current_rake_file,
)
Rake::Task.define_task(:environment)
end
end