From dbddd40c1c69e66c6b11cd7b42de2a69456581d6 Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Thu, 21 Nov 2024 15:06:57 +0100
Subject: [PATCH] Add stop-gap antispam code (#32981)

---
 app/lib/antispam.rb                 | 45 +++++++++++++++++++++++++++++
 app/services/post_status_service.rb |  8 +++++
 2 files changed, 53 insertions(+)
 create mode 100644 app/lib/antispam.rb

diff --git a/app/lib/antispam.rb b/app/lib/antispam.rb
new file mode 100644
index 000000000..bc4841280
--- /dev/null
+++ b/app/lib/antispam.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class Antispam
+  include Redisable
+
+  ACCOUNT_AGE_EXEMPTION = 1.week.freeze
+
+  class SilentlyDrop < StandardError
+    attr_reader :status
+
+    def initialize(status)
+      super()
+
+      @status = status
+
+      status.created_at = Time.now.utc
+      status.id = Mastodon::Snowflake.id_at(status.created_at)
+      status.in_reply_to_account_id = status.thread&.account_id
+
+      status.delete # Make sure this is not persisted
+    end
+  end
+
+  def local_preflight_check!(status)
+    return unless spammy_texts.any? { |spammy_text| status.text.include?(spammy_text) }
+    return unless status.thread.present? && !status.thread.account.following?(status.account)
+    return unless status.account.created_at >= ACCOUNT_AGE_EXEMPTION.ago
+
+    report_if_needed!(status.account)
+
+    raise SilentlyDrop, status
+  end
+
+  private
+
+  def spammy_texts
+    redis.smembers('antispam:spammy_texts')
+  end
+
+  def report_if_needed!(account)
+    return if Report.unresolved.exists?(account: Account.representative, target_account: account)
+
+    Report.create!(account: Account.representative, target_account: account, category: :spam, comment: 'Account automatically reported for posting a banned URL')
+  end
+end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index ee6b18c74..765c80723 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -36,6 +36,8 @@ class PostStatusService < BaseService
     @text        = @options[:text] || ''
     @in_reply_to = @options[:thread]
 
+    @antispam = Antispam.new
+
     return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
 
     validate_media!
@@ -55,6 +57,8 @@ class PostStatusService < BaseService
     end
 
     @status
+  rescue Antispam::SilentlyDrop => e
+    e.status
   end
 
   private
@@ -74,6 +78,7 @@ class PostStatusService < BaseService
     @status = @account.statuses.new(status_attributes)
     process_mentions_service.call(@status, save_records: false)
     safeguard_mentions!(@status)
+    @antispam.local_preflight_check!(@status)
 
     # The following transaction block is needed to wrap the UPDATEs to
     # the media attachments when the status is created
@@ -95,6 +100,7 @@ class PostStatusService < BaseService
 
   def schedule_status!
     status_for_validation = @account.statuses.build(status_attributes)
+    @antispam.local_preflight_check!(status_for_validation)
 
     if status_for_validation.valid?
       # Marking the status as destroyed is necessary to prevent the status from being
@@ -111,6 +117,8 @@ class PostStatusService < BaseService
     else
       raise ActiveRecord::RecordInvalid
     end
+  rescue Antispam::SilentlyDrop
+    @status = @account.scheduled_status.new(scheduled_status_attributes).tap(&:delete)
   end
 
   def postprocess_status!