From 42c4427cb15a54ca4e7703c41a4521be19beb937 Mon Sep 17 00:00:00 2001 From: Ted Johansson Date: Tue, 4 Mar 2025 17:29:38 +0800 Subject: [PATCH] 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. --- app/services/destroy_task.rb | 28 ++++++++++++++++++++++++++++ config/locales/server.en.yml | 1 + lib/tasks/destroy.rake | 13 +++++++++++++ spec/lib/tasks/destroy_rake_spec.rb | 21 +++++++++++++++++++++ spec/services/destroy_task_spec.rb | 24 ++++++++++++++++++++++++ spec/support/rake_context.rb | 26 ++++++++++++++++++++++++++ 6 files changed, 113 insertions(+) create mode 100644 spec/lib/tasks/destroy_rake_spec.rb create mode 100644 spec/support/rake_context.rb diff --git a/app/services/destroy_task.rb b/app/services/destroy_task.rb index fda78c9583d..1b43115cc6d 100644 --- a/app/services/destroy_task.rb +++ b/app/services/destroy_task.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "highline/import" + class DestroyTask def initialize(io = STDOUT) @io = io @@ -46,6 +48,32 @@ class DestroyTask categories.each { |c| @io.puts destroy_topics(c.slug, c.parent_category&.slug) } 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 Topic .where(archetype: "private_message") diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index e7e6d2e209b..5975c6a081b 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -5560,6 +5560,7 @@ en: revoked: Revoked restored: Restored bulk_user_delete: "deleted in a bulk delete operation" + cli_bulk_post_delete: "Bulk deleted from rake task" reviewables: already_handled: "Thanks, but we've already reviewed that post and determined it does not need to be flagged again." diff --git a/lib/tasks/destroy.rake b/lib/tasks/destroy.rake index 1708c701eee..5a6d9d6c227 100644 --- a/lib/tasks/destroy.rake +++ b/lib/tasks/destroy.rake @@ -50,3 +50,16 @@ task "destroy:categories" => :environment do |t, args| puts "Going to delete these categories: #{categories}" categories.each { |id| destroy_task.destroy_category(id, true) } 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 diff --git a/spec/lib/tasks/destroy_rake_spec.rb b/spec/lib/tasks/destroy_rake_spec.rb new file mode 100644 index 00000000000..e07999661d2 --- /dev/null +++ b/spec/lib/tasks/destroy_rake_spec.rb @@ -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 diff --git a/spec/services/destroy_task_spec.rb b/spec/services/destroy_task_spec.rb index e4fccb804d8..140685c5c63 100644 --- a/spec/services/destroy_task_spec.rb +++ b/spec/services/destroy_task_spec.rb @@ -67,6 +67,30 @@ RSpec.describe DestroyTask do 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 let!(:pm) { Fabricate(:private_message_post) } let!(:pm2) { Fabricate(:private_message_post) } diff --git a/spec/support/rake_context.rb b/spec/support/rake_context.rb new file mode 100644 index 00000000000..ef4dd5ae428 --- /dev/null +++ b/spec/support/rake_context.rb @@ -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