about summary refs log tree commit diff
path: root/app/services
diff options
context:
space:
mode:
Diffstat (limited to 'app/services')
-rw-r--r--app/services/account_search_service.rb19
-rw-r--r--app/services/account_statuses_cleanup_service.rb2
-rw-r--r--app/services/activitypub/fetch_featured_collection_service.rb30
-rw-r--r--app/services/activitypub/fetch_featured_tags_collection_service.rb74
-rw-r--r--app/services/activitypub/fetch_remote_account_service.rb66
-rw-r--r--app/services/activitypub/fetch_remote_actor_service.rb80
-rw-r--r--app/services/activitypub/fetch_remote_key_service.rb48
-rw-r--r--app/services/activitypub/process_account_service.rb11
-rw-r--r--app/services/activitypub/process_collection_service.rb11
-rw-r--r--app/services/clear_domain_media_service.rb32
-rw-r--r--app/services/create_featured_tag_service.rb25
-rw-r--r--app/services/fan_out_on_write_service.rb11
-rw-r--r--app/services/fetch_resource_service.rb2
-rw-r--r--app/services/follow_service.rb13
-rw-r--r--app/services/import_service.rb7
-rw-r--r--app/services/keys/claim_service.rb2
-rw-r--r--app/services/process_mentions_service.rb12
-rw-r--r--app/services/remove_featured_tag_service.rb18
-rw-r--r--app/services/remove_status_service.rb10
-rw-r--r--app/services/report_service.rb2
-rw-r--r--app/services/resolve_account_service.rb15
-rw-r--r--app/services/resolve_url_service.rb8
-rw-r--r--app/services/translate_status_service.rb27
-rw-r--r--app/services/update_account_service.rb2
24 files changed, 393 insertions, 134 deletions
diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb
index 4dcae20eb..85538870b 100644
--- a/app/services/account_search_service.rb
+++ b/app/services/account_search_service.rb
@@ -3,6 +3,11 @@
 class AccountSearchService < BaseService
   attr_reader :query, :limit, :offset, :options, :account
 
+  MENTION_ONLY_RE = /\A#{Account::MENTION_RE}\z/i
+
+  # Min. number of characters to look for non-exact matches
+  MIN_QUERY_LENGTH = 5
+
   def call(query, account = nil, options = {})
     @acct_hint = query&.start_with?('@')
     @query     = query&.strip&.gsub(/\A@/, '')
@@ -102,7 +107,7 @@ class AccountSearchService < BaseService
     {
       script_score: {
         script: {
-          source: "(doc['followers_count'].value + 0.0) / (doc['followers_count'].value + doc['following_count'].value + 1)",
+          source: "(Math.max(doc['followers_count'].value, 0) + 0.0) / (Math.max(doc['followers_count'].value, 0) + Math.max(doc['following_count'].value, 0) + 1)",
         },
       },
     }
@@ -110,10 +115,10 @@ class AccountSearchService < BaseService
 
   def followers_score_function
     {
-      field_value_factor: {
-        field: 'followers_count',
-        modifier: 'log2p',
-        missing: 0,
+      script_score: {
+        script: {
+          source: "Math.log10(Math.max(doc['followers_count'].value, 0) + 2)",
+        },
       },
     }
   end
@@ -135,6 +140,8 @@ class AccountSearchService < BaseService
   end
 
   def limit_for_non_exact_results
+    return 0 if @account.nil? && query.size < MIN_QUERY_LENGTH
+
     if exact_match?
       limit - 1
     else
@@ -175,7 +182,7 @@ class AccountSearchService < BaseService
   end
 
   def username_complete?
-    query.include?('@') && "@#{query}".match?(/\A#{Account::MENTION_RE}\Z/)
+    query.include?('@') && "@#{query}".match?(MENTION_ONLY_RE)
   end
 
   def likely_acct?
diff --git a/app/services/account_statuses_cleanup_service.rb b/app/services/account_statuses_cleanup_service.rb
index 3918b5ba4..96bc3db7d 100644
--- a/app/services/account_statuses_cleanup_service.rb
+++ b/app/services/account_statuses_cleanup_service.rb
@@ -14,7 +14,7 @@ class AccountStatusesCleanupService < BaseService
     last_deleted = nil
 
     account_policy.statuses_to_delete(budget, cutoff_id, account_policy.last_inspected).reorder(nil).find_each(order: :asc) do |status|
-      status.discard
+      status.discard_with_reblogs
       RemovalWorker.perform_async(status.id, { 'redraft' => false })
       num_deleted += 1
       last_deleted = status.id
diff --git a/app/services/activitypub/fetch_featured_collection_service.rb b/app/services/activitypub/fetch_featured_collection_service.rb
index 37d05e055..50a187ad9 100644
--- a/app/services/activitypub/fetch_featured_collection_service.rb
+++ b/app/services/activitypub/fetch_featured_collection_service.rb
@@ -3,10 +3,11 @@
 class ActivityPub::FetchFeaturedCollectionService < BaseService
   include JsonLdHelper
 
-  def call(account)
+  def call(account, **options)
     return if account.featured_collection_url.blank? || account.suspended? || account.local?
 
     @account = account
+    @options = options
     @json    = fetch_resource(@account.featured_collection_url, true, local_follower)
 
     return unless supported_context?(@json)
@@ -36,7 +37,14 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService
   end
 
   def process_items(items)
+    process_note_items(items) if @options[:note]
+    process_hashtag_items(items) if @options[:hashtag]
+  end
+
+  def process_note_items(items)
     status_ids = items.filter_map do |item|
+      next unless item.is_a?(String) || item['type'] == 'Note'
+
       uri = value_or_id(item)
       next if ActivityPub::TagManager.instance.local_uri?(uri)
 
@@ -67,6 +75,26 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService
     end
   end
 
+  def process_hashtag_items(items)
+    names     = items.filter_map { |item| item['type'] == 'Hashtag' && item['name']&.delete_prefix('#') }.map { |name| HashtagNormalizer.new.normalize(name) }
+    to_remove = []
+    to_add    = names
+
+    FeaturedTag.where(account: @account).map(&:name).each do |name|
+      if names.include?(name)
+        to_add.delete(name)
+      else
+        to_remove << name
+      end
+    end
+
+    FeaturedTag.includes(:tag).where(account: @account, tags: { name: to_remove }).delete_all unless to_remove.empty?
+
+    to_add.each do |name|
+      FeaturedTag.create!(account: @account, name: name)
+    end
+  end
+
   def local_follower
     return @local_follower if defined?(@local_follower)
 
diff --git a/app/services/activitypub/fetch_featured_tags_collection_service.rb b/app/services/activitypub/fetch_featured_tags_collection_service.rb
new file mode 100644
index 000000000..ab047a0f8
--- /dev/null
+++ b/app/services/activitypub/fetch_featured_tags_collection_service.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchFeaturedTagsCollectionService < BaseService
+  include JsonLdHelper
+
+  def call(account, url)
+    return if url.blank? || account.suspended? || account.local?
+
+    @account = account
+    @json    = fetch_resource(url, true, local_follower)
+
+    return unless supported_context?(@json)
+
+    process_items(collection_items(@json))
+  end
+
+  private
+
+  def collection_items(collection)
+    all_items = []
+
+    collection = fetch_collection(collection['first']) if collection['first'].present?
+
+    while collection.is_a?(Hash)
+      items = begin
+        case collection['type']
+        when 'Collection', 'CollectionPage'
+          collection['items']
+        when 'OrderedCollection', 'OrderedCollectionPage'
+          collection['orderedItems']
+        end
+      end
+
+      break if items.blank?
+
+      all_items.concat(items)
+
+      break if all_items.size >= FeaturedTag::LIMIT
+
+      collection = collection['next'].present? ? fetch_collection(collection['next']) : nil
+    end
+
+    all_items
+  end
+
+  def fetch_collection(collection_or_uri)
+    return collection_or_uri if collection_or_uri.is_a?(Hash)
+    return if invalid_origin?(collection_or_uri)
+
+    fetch_resource_without_id_validation(collection_or_uri, local_follower, true)
+  end
+
+  def process_items(items)
+    names            = items.filter_map { |item| item['type'] == 'Hashtag' && item['name']&.delete_prefix('#') }.take(FeaturedTag::LIMIT)
+    tags             = names.index_by { |name| HashtagNormalizer.new.normalize(name) }
+    normalized_names = tags.keys
+
+    FeaturedTag.includes(:tag).references(:tag).where(account: @account).where.not(tag: { name: normalized_names }).delete_all
+
+    FeaturedTag.includes(:tag).references(:tag).where(account: @account, tag: { name: normalized_names }).each do |featured_tag|
+      featured_tag.update(name: tags.delete(featured_tag.tag.name))
+    end
+
+    tags.each_value do |name|
+      FeaturedTag.create!(account: @account, name: name)
+    end
+  end
+
+  def local_follower
+    return @local_follower if defined?(@local_follower)
+
+    @local_follower = @account.followers.local.without_suspended.first
+  end
+end
diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb
index 9d01f5386..ca7a8c6ca 100644
--- a/app/services/activitypub/fetch_remote_account_service.rb
+++ b/app/services/activitypub/fetch_remote_account_service.rb
@@ -1,66 +1,12 @@
 # frozen_string_literal: true
 
-class ActivityPub::FetchRemoteAccountService < BaseService
-  include JsonLdHelper
-  include DomainControlHelper
-  include WebfingerHelper
-
-  SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
-
+class ActivityPub::FetchRemoteAccountService < ActivityPub::FetchRemoteActorService
   # Does a WebFinger roundtrip on each call, unless `only_key` is true
-  def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false)
-    return if domain_not_allowed?(uri)
-    return ActivityPub::TagManager.instance.uri_to_resource(uri, Account) if ActivityPub::TagManager.instance.local_uri?(uri)
-
-    @json = begin
-      if prefetched_body.nil?
-        fetch_resource(uri, id)
-      else
-        body_to_json(prefetched_body, compare_id: id ? uri : nil)
-      end
-    end
-
-    return if !supported_context? || !expected_type? || (break_on_redirect && @json['movedTo'].present?)
-
-    @uri      = @json['id']
-    @username = @json['preferredUsername']
-    @domain   = Addressable::URI.parse(@uri).normalized_host
-
-    return unless only_key || verified_webfinger?
-
-    ActivityPub::ProcessAccountService.new.call(@username, @domain, @json, only_key: only_key, verified_webfinger: !only_key)
-  rescue Oj::ParseError
-    nil
-  end
-
-  private
-
-  def verified_webfinger?
-    webfinger                            = webfinger!("acct:#{@username}@#{@domain}")
-    confirmed_username, confirmed_domain = split_acct(webfinger.subject)
-
-    return webfinger.link('self', 'href') == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
-
-    webfinger                            = webfinger!("acct:#{confirmed_username}@#{confirmed_domain}")
-    @username, @domain                   = split_acct(webfinger.subject)
-
-    return false unless @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
-    return false if webfinger.link('self', 'href') != @uri
-
-    true
-  rescue Webfinger::Error
-    false
-  end
-
-  def split_acct(acct)
-    acct.gsub(/\Aacct:/, '').split('@')
-  end
-
-  def supported_context?
-    super(@json)
-  end
+  def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true)
+    actor = super
+    return actor if actor.nil? || actor.is_a?(Account)
 
-  def expected_type?
-    equals_or_includes_any?(@json['type'], SUPPORTED_TYPES)
+    Rails.logger.debug "Fetching account #{uri} failed: Expected Account, got #{actor.class.name}"
+    raise Error, "Expected Account, got #{actor.class.name}" unless suppress_errors
   end
 end
diff --git a/app/services/activitypub/fetch_remote_actor_service.rb b/app/services/activitypub/fetch_remote_actor_service.rb
new file mode 100644
index 000000000..db09c38d8
--- /dev/null
+++ b/app/services/activitypub/fetch_remote_actor_service.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchRemoteActorService < BaseService
+  include JsonLdHelper
+  include DomainControlHelper
+  include WebfingerHelper
+
+  class Error < StandardError; end
+
+  SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
+
+  # Does a WebFinger roundtrip on each call, unless `only_key` is true
+  def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true)
+    return if domain_not_allowed?(uri)
+    return ActivityPub::TagManager.instance.uri_to_actor(uri) if ActivityPub::TagManager.instance.local_uri?(uri)
+
+    @json = begin
+      if prefetched_body.nil?
+        fetch_resource(uri, id)
+      else
+        body_to_json(prefetched_body, compare_id: id ? uri : nil)
+      end
+    rescue Oj::ParseError
+      raise Error, "Error parsing JSON-LD document #{uri}"
+    end
+
+    raise Error, "Error fetching actor JSON at #{uri}" if @json.nil?
+    raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?
+    raise Error, "Unexpected object type for actor #{uri} (expected any of: #{SUPPORTED_TYPES})" unless expected_type?
+    raise Error, "Actor #{uri} has moved to #{@json['movedTo']}" if break_on_redirect && @json['movedTo'].present?
+
+    @uri      = @json['id']
+    @username = @json['preferredUsername']
+    @domain   = Addressable::URI.parse(@uri).normalized_host
+
+    check_webfinger! unless only_key
+
+    ActivityPub::ProcessAccountService.new.call(@username, @domain, @json, only_key: only_key, verified_webfinger: !only_key)
+  rescue Error => e
+    Rails.logger.debug "Fetching actor #{uri} failed: #{e.message}"
+    raise unless suppress_errors
+  end
+
+  private
+
+  def check_webfinger!
+    webfinger                            = webfinger!("acct:#{@username}@#{@domain}")
+    confirmed_username, confirmed_domain = split_acct(webfinger.subject)
+
+    if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
+      raise Error, "Webfinger response for #{@username}@#{@domain} does not loop back to #{@uri}" if webfinger.link('self', 'href') != @uri
+      return
+    end
+
+    webfinger                            = webfinger!("acct:#{confirmed_username}@#{confirmed_domain}")
+    @username, @domain                   = split_acct(webfinger.subject)
+
+    unless confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero?
+      raise Webfinger::RedirectError, "Too many webfinger redirects for URI #{@uri} (stopped at #{@username}@#{@domain})"
+    end
+
+    raise Error, "Webfinger response for #{@username}@#{@domain} does not loop back to #{@uri}" if webfinger.link('self', 'href') != @uri
+  rescue Webfinger::RedirectError => e
+    raise Error, e.message
+  rescue Webfinger::Error => e
+    raise Error, "Webfinger error when resolving #{@username}@#{@domain}: #{e.message}"
+  end
+
+  def split_acct(acct)
+    acct.gsub(/\Aacct:/, '').split('@')
+  end
+
+  def supported_context?
+    super(@json)
+  end
+
+  def expected_type?
+    equals_or_includes_any?(@json['type'], SUPPORTED_TYPES)
+  end
+end
diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb
index c48288b3b..32e82b47a 100644
--- a/app/services/activitypub/fetch_remote_key_service.rb
+++ b/app/services/activitypub/fetch_remote_key_service.rb
@@ -3,17 +3,19 @@
 class ActivityPub::FetchRemoteKeyService < BaseService
   include JsonLdHelper
 
-  # Returns account that owns the key
-  def call(uri, id: true, prefetched_body: nil)
-    return if uri.blank?
+  class Error < StandardError; end
+
+  # Returns actor that owns the key
+  def call(uri, id: true, prefetched_body: nil, suppress_errors: true)
+    raise Error, 'No key URI given' if uri.blank?
 
     if prefetched_body.nil?
       if id
         @json = fetch_resource_without_id_validation(uri)
-        if person?
+        if actor_type?
           @json = fetch_resource(@json['id'], true)
         elsif uri != @json['id']
-          return
+          raise Error, "Fetched URI #{uri} has wrong id #{@json['id']}"
         end
       else
         @json = fetch_resource(uri, id)
@@ -22,30 +24,38 @@ class ActivityPub::FetchRemoteKeyService < BaseService
       @json = body_to_json(prefetched_body, compare_id: id ? uri : nil)
     end
 
-    return unless supported_context?(@json) && expected_type?
-    return find_account(@json['id'], @json) if person?
+    raise Error, "Unable to fetch key JSON at #{uri}" if @json.nil?
+    raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?(@json)
+    raise Error, "Unexpected object type for key #{uri}" unless expected_type?
+    return find_actor(@json['id'], @json, suppress_errors) if actor_type?
 
     @owner = fetch_resource(owner_uri, true)
 
-    return unless supported_context?(@owner) && confirmed_owner?
+    raise Error, "Unable to fetch actor JSON #{owner_uri}" if @owner.nil?
+    raise Error, "Unsupported JSON-LD context for document #{owner_uri}" unless supported_context?(@owner)
+    raise Error, "Unexpected object type for actor #{owner_uri} (expected any of: #{SUPPORTED_TYPES})" unless expected_owner_type?
+    raise Error, "publicKey id for #{owner_uri} does not correspond to #{@json['id']}" unless confirmed_owner?
 
-    find_account(owner_uri, @owner)
+    find_actor(owner_uri, @owner, suppress_errors)
+  rescue Error => e
+    Rails.logger.debug "Fetching key #{uri} failed: #{e.message}"
+    raise unless suppress_errors
   end
 
   private
 
-  def find_account(uri, prefetched_body)
-    account   = ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
-    account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_body: prefetched_body)
-    account
+  def find_actor(uri, prefetched_body, suppress_errors)
+    actor   = ActivityPub::TagManager.instance.uri_to_actor(uri)
+    actor ||= ActivityPub::FetchRemoteActorService.new.call(uri, prefetched_body: prefetched_body, suppress_errors: suppress_errors)
+    actor
   end
 
   def expected_type?
-    person? || public_key?
+    actor_type? || public_key?
   end
 
-  def person?
-    equals_or_includes_any?(@json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES)
+  def actor_type?
+    equals_or_includes_any?(@json['type'], ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES)
   end
 
   def public_key?
@@ -56,7 +66,11 @@ class ActivityPub::FetchRemoteKeyService < BaseService
     @owner_uri ||= value_or_id(@json['owner'])
   end
 
+  def expected_owner_type?
+    equals_or_includes_any?(@owner['type'], ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES)
+  end
+
   def confirmed_owner?
-    equals_or_includes_any?(@owner['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) && value_or_id(@owner['publicKey']) == @json['id']
+    value_or_id(@owner['publicKey']) == @json['id']
   end
 end
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index 34750dba6..99bcb3835 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -32,8 +32,6 @@ class ActivityPub::ProcessAccountService < BaseService
       process_duplicate_accounts! if @options[:verified_webfinger]
     end
 
-    return if @account.nil?
-
     after_protocol_change! if protocol_changed?
     after_key_change! if key_changed? && !@options[:signed_with_known_key]
     clear_tombstones! if key_changed?
@@ -41,7 +39,8 @@ class ActivityPub::ProcessAccountService < BaseService
 
     unless @options[:only_key] || @account.suspended?
       check_featured_collection! if @account.featured_collection_url.present?
-      check_links! unless @account.fields.empty?
+      check_featured_tags_collection! if @json['featuredTags'].present?
+      check_links! if @account.fields.any?(&:requires_verification?)
     end
 
     @account
@@ -151,7 +150,11 @@ class ActivityPub::ProcessAccountService < BaseService
   end
 
   def check_featured_collection!
-    ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id)
+    ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id, { 'hashtag' => @json['featuredTags'].blank? })
+  end
+
+  def check_featured_tags_collection!
+    ActivityPub::SynchronizeFeaturedTagsCollectionWorker.perform_async(@account.id, @json['featuredTags'])
   end
 
   def check_links!
diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb
index eb008c40a..fffe30195 100644
--- a/app/services/activitypub/process_collection_service.rb
+++ b/app/services/activitypub/process_collection_service.rb
@@ -3,8 +3,8 @@
 class ActivityPub::ProcessCollectionService < BaseService
   include JsonLdHelper
 
-  def call(body, account, **options)
-    @account = account
+  def call(body, actor, **options)
+    @account = actor
     @json    = original_json = Oj.load(body, mode: :strict)
     @options = options
 
@@ -16,6 +16,7 @@ class ActivityPub::ProcessCollectionService < BaseService
     end
 
     return if !supported_context? || (different_actor? && verify_account!.nil?) || suspended_actor? || @account.local?
+    return unless @account.is_a?(Account)
 
     if @json['signature'].present?
       # We have verified the signature, but in the compaction step above, might
@@ -66,8 +67,10 @@ class ActivityPub::ProcessCollectionService < BaseService
   end
 
   def verify_account!
-    @options[:relayed_through_account] = @account
-    @account = ActivityPub::LinkedDataSignature.new(@json).verify_account!
+    @options[:relayed_through_actor] = @account
+    @account = ActivityPub::LinkedDataSignature.new(@json).verify_actor!
+    @account = nil unless @account.is_a?(Account)
+    @account
   rescue JSON::LD::JsonLdError => e
     Rails.logger.debug "Could not verify LD-Signature for #{value_or_id(@json['actor'])}: #{e.message}"
     nil
diff --git a/app/services/clear_domain_media_service.rb b/app/services/clear_domain_media_service.rb
index 704cfb71a..9e70ebe51 100644
--- a/app/services/clear_domain_media_service.rb
+++ b/app/services/clear_domain_media_service.rb
@@ -10,24 +10,18 @@ class ClearDomainMediaService < BaseService
 
   private
 
-  def invalidate_association_caches!
+  def invalidate_association_caches!(status_ids)
     # Normally, associated models of a status are immutable (except for accounts)
     # so they are aggressively cached. After updating the media attachments to no
     # longer point to a local file, we need to clear the cache to make those
     # changes appear in the API and UI
-    @affected_status_ids.each { |id| Rails.cache.delete_matched("statuses/#{id}-*") }
+    Rails.cache.delete_multi(status_ids.map { |id| "statuses/#{id}" })
   end
 
   def clear_media!
-    @affected_status_ids = []
-
-    begin
-      clear_account_images!
-      clear_account_attachments!
-      clear_emojos!
-    ensure
-      invalidate_association_caches!
-    end
+    clear_account_images!
+    clear_account_attachments!
+    clear_emojos!
   end
 
   def clear_account_images!
@@ -39,12 +33,18 @@ class ClearDomainMediaService < BaseService
   end
 
   def clear_account_attachments!
-    media_from_blocked_domain.reorder(nil).find_each do |attachment|
-      @affected_status_ids << attachment.status_id if attachment.status_id.present?
+    media_from_blocked_domain.reorder(nil).find_in_batches do |attachments|
+      affected_status_ids = []
+
+      attachments.each do |attachment|
+        affected_status_ids << attachment.status_id if attachment.status_id.present?
+
+        attachment.file.destroy if attachment.file&.exists?
+        attachment.type = :unknown
+        attachment.save
+      end
 
-      attachment.file.destroy if attachment.file&.exists?
-      attachment.type = :unknown
-      attachment.save
+      invalidate_association_caches!(affected_status_ids) unless affected_status_ids.empty?
     end
   end
 
diff --git a/app/services/create_featured_tag_service.rb b/app/services/create_featured_tag_service.rb
new file mode 100644
index 000000000..3cc59156d
--- /dev/null
+++ b/app/services/create_featured_tag_service.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class CreateFeaturedTagService < BaseService
+  include Payloadable
+
+  def call(account, name, force: true)
+    @account = account
+
+    FeaturedTag.create!(account: account, name: name).tap do |featured_tag|
+      ActivityPub::AccountRawDistributionWorker.perform_async(build_json(featured_tag), account.id) if @account.local?
+    end
+  rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
+    if force && e.is_a(ActiveRecord::RecordNotUnique)
+      FeaturedTag.by_name(name).find_by!(account: account)
+    else
+      account.featured_tags.new(name: name)
+    end
+  end
+
+  private
+
+  def build_json(featured_tag)
+    Oj.dump(serialize_payload(featured_tag, ActivityPub::AddSerializer, signer: @account))
+  end
+end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 1df522459..8e74e152e 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -14,6 +14,7 @@ class FanOutOnWriteService < BaseService
     @options   = options
 
     check_race_condition!
+    warm_payload_cache!
 
     fan_out_to_local_recipients!
     fan_out_to_public_recipients! if broadcastable?
@@ -143,13 +144,21 @@ class FanOutOnWriteService < BaseService
     AccountConversation.add_status(@account, @status) unless update?
   end
 
+  def warm_payload_cache!
+    Rails.cache.write("fan-out/#{@status.id}", rendered_status)
+  end
+
   def anonymous_payload
     @anonymous_payload ||= Oj.dump(
       event: update? ? :'status.update' : :update,
-      payload: InlineRenderer.render(@status, nil, :status)
+      payload: rendered_status
     )
   end
 
+  def rendered_status
+    @rendered_status ||= InlineRenderer.render(@status, nil, :status)
+  end
+
   def update?
     @options[:update]
   end
diff --git a/app/services/fetch_resource_service.rb b/app/services/fetch_resource_service.rb
index 6c0093cd4..73204e55d 100644
--- a/app/services/fetch_resource_service.rb
+++ b/app/services/fetch_resource_service.rb
@@ -47,7 +47,7 @@ class FetchResourceService < BaseService
       body = response.body_with_limit
       json = body_to_json(body)
 
-      [json['id'], { prefetched_body: body, id: true }] if supported_context?(json) && (equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) || expected_type?(json))
+      [json['id'], { prefetched_body: body, id: true }] if supported_context?(json) && (equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES) || expected_type?(json))
     elsif !terminal
       link_header = response['Link'] && parse_link_header(response)
 
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index ed28e1371..feea40e3c 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -11,6 +11,7 @@ class FollowService < BaseService
   # @param [Hash] options
   # @option [Boolean] :reblogs Whether or not to show reblogs, defaults to true
   # @option [Boolean] :notify Whether to create notifications about new posts, defaults to false
+  # @option [Array<String>] :languages Which languages to allow on the home feed from this account, defaults to all
   # @option [Boolean] :bypass_locked
   # @option [Boolean] :bypass_limit Allow following past the total follow number
   # @option [Boolean] :with_rate_limit
@@ -57,15 +58,15 @@ class FollowService < BaseService
   end
 
   def change_follow_options!
-    @source_account.follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify])
+    @source_account.follow!(@target_account, **follow_options)
   end
 
   def change_follow_request_options!
-    @source_account.request_follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify])
+    @source_account.request_follow!(@target_account, **follow_options)
   end
 
   def request_follow!
-    follow_request = @source_account.request_follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify], rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit])
+    follow_request = @source_account.request_follow!(@target_account, **follow_options.merge(rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit]))
 
     if @target_account.local?
       LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, 'follow_request')
@@ -77,7 +78,7 @@ class FollowService < BaseService
   end
 
   def direct_follow!
-    follow = @source_account.follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify], rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit])
+    follow = @source_account.follow!(@target_account, **follow_options.merge(rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit]))
 
     LocalNotificationWorker.perform_async(@target_account.id, follow.id, follow.class.name, 'follow')
     MergeWorker.perform_async(@target_account.id, @source_account.id)
@@ -88,4 +89,8 @@ class FollowService < BaseService
   def build_json(follow_request)
     Oj.dump(serialize_payload(follow_request, ActivityPub::FollowSerializer))
   end
+
+  def follow_options
+    @options.slice(:reblogs, :notify, :languages)
+  end
 end
diff --git a/app/services/import_service.rb b/app/services/import_service.rb
index 8e6640b9d..ece5b9ef0 100644
--- a/app/services/import_service.rb
+++ b/app/services/import_service.rb
@@ -27,7 +27,7 @@ class ImportService < BaseService
 
   def import_follows!
     parse_import_data!(['Account address'])
-    import_relationships!('follow', 'unfollow', @account.following, ROWS_PROCESSING_LIMIT, reblogs: { header: 'Show boosts', default: true })
+    import_relationships!('follow', 'unfollow', @account.following, ROWS_PROCESSING_LIMIT, reblogs: { header: 'Show boosts', default: true }, notify: { header: 'Notify on new posts', default: false }, languages: { header: 'Languages', default: nil })
   end
 
   def import_blocks!
@@ -112,6 +112,11 @@ class ImportService < BaseService
       next if status.nil? && ActivityPub::TagManager.instance.local_uri?(uri)
 
       status || ActivityPub::FetchRemoteStatusService.new.call(uri)
+    rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError
+      nil
+    rescue StandardError => e
+      Rails.logger.warn "Unexpected error when importing bookmark: #{e}"
+      nil
     end
 
     account_ids         = statuses.map(&:account_id)
diff --git a/app/services/keys/claim_service.rb b/app/services/keys/claim_service.rb
index 69568a0d1..ae9e24a24 100644
--- a/app/services/keys/claim_service.rb
+++ b/app/services/keys/claim_service.rb
@@ -72,7 +72,7 @@ class Keys::ClaimService < BaseService
 
   def build_post_request(uri)
     Request.new(:post, uri).tap do |request|
-      request.on_behalf_of(@source_account, :uri)
+      request.on_behalf_of(@source_account)
       request.add_headers(HEADERS)
     end
   end
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index 8c63b611d..b117db8c2 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -38,7 +38,7 @@ class ProcessMentionsService < BaseService
       mentioned_account = Account.find_remote(username, domain)
 
       # Unapproved and unconfirmed accounts should not be mentionable
-      next if mentioned_account&.local? && !(mentioned_account.user_confirmed? && mentioned_account.user_approved?)
+      next match if mentioned_account&.local? && !(mentioned_account.user_confirmed? && mentioned_account.user_approved?)
 
       # If the account cannot be found or isn't the right protocol,
       # first try to resolve it
@@ -66,6 +66,16 @@ class ProcessMentionsService < BaseService
   end
 
   def assign_mentions!
+    # Make sure we never mention blocked accounts
+    unless @current_mentions.empty?
+      mentioned_domains = @current_mentions.map { |m| m.account.domain }.compact.uniq
+      blocked_domains   = Set.new(mentioned_domains.empty? ? [] : AccountDomainBlock.where(account_id: @status.account_id, domain: mentioned_domains))
+      mentioned_account_ids = @current_mentions.map(&:account_id)
+      blocked_account_ids = Set.new(@status.account.block_relationships.where(target_account_id: mentioned_account_ids).pluck(:target_account_id))
+
+      @current_mentions.select! { |mention| !(blocked_account_ids.include?(mention.account_id) || blocked_domains.include?(mention.account.domain)) }
+    end
+
     @current_mentions.each do |mention|
       mention.save if mention.new_record?
     end
diff --git a/app/services/remove_featured_tag_service.rb b/app/services/remove_featured_tag_service.rb
new file mode 100644
index 000000000..2aa70e8fc
--- /dev/null
+++ b/app/services/remove_featured_tag_service.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class RemoveFeaturedTagService < BaseService
+  include Payloadable
+
+  def call(account, featured_tag)
+    @account = account
+
+    featured_tag.destroy!
+    ActivityPub::AccountRawDistributionWorker.perform_async(build_json(featured_tag), account.id) if @account.local?
+  end
+
+  private
+
+  def build_json(featured_tag)
+    Oj.dump(serialize_payload(featured_tag, ActivityPub::RemoveSerializer, signer: @account))
+  end
+end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 97afc3f61..58bf1dcb7 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -19,7 +19,9 @@ class RemoveStatusService < BaseService
     @options  = options
 
     with_lock("distribute:#{@status.id}") do
-      @status.discard
+      @status.discard_with_reblogs
+
+      StatusPin.find_by(status: @status)&.destroy
 
       remove_from_self if @account.local?
       remove_from_followers
@@ -57,13 +59,13 @@ class RemoveStatusService < BaseService
   end
 
   def remove_from_followers
-    @account.followers_for_local_distribution.reorder(nil).find_each do |follower|
+    @account.followers_for_local_distribution.includes(:user).reorder(nil).find_each do |follower|
       FeedManager.instance.unpush_from_home(follower, @status)
     end
   end
 
   def remove_from_lists
-    @account.lists_for_local_distribution.select(:id, :account_id).reorder(nil).find_each do |list|
+    @account.lists_for_local_distribution.select(:id, :account_id).includes(account: :user).reorder(nil).find_each do |list|
       FeedManager.instance.unpush_from_list(list, @status)
     end
   end
@@ -102,7 +104,7 @@ class RemoveStatusService < BaseService
     # because once original status is gone, reblogs will disappear
     # without us being able to do all the fancy stuff
 
-    @status.reblogs.includes(:account).reorder(nil).find_each do |reblog|
+    @status.reblogs.rewhere(deleted_at: [nil, @status.deleted_at]).includes(:account).reorder(nil).find_each do |reblog|
       RemoveStatusService.new.call(reblog, original_removed: true)
     end
   end
diff --git a/app/services/report_service.rb b/app/services/report_service.rb
index 8c92cf334..0ce525b07 100644
--- a/app/services/report_service.rb
+++ b/app/services/report_service.rb
@@ -8,7 +8,7 @@ class ReportService < BaseService
     @target_account = target_account
     @status_ids     = options.delete(:status_ids).presence || []
     @comment        = options.delete(:comment).presence || ''
-    @category       = options.delete(:category).presence || 'other'
+    @category       = options[:rule_ids].present? ? 'violation' : (options.delete(:category).presence || 'other')
     @rule_ids       = options.delete(:rule_ids).presence
     @options        = options
 
diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb
index b55e45409..d8b81a7b9 100644
--- a/app/services/resolve_account_service.rb
+++ b/app/services/resolve_account_service.rb
@@ -1,7 +1,6 @@
 # frozen_string_literal: true
 
 class ResolveAccountService < BaseService
-  include JsonLdHelper
   include DomainControlHelper
   include WebfingerHelper
   include Redisable
@@ -13,6 +12,8 @@ class ResolveAccountService < BaseService
   # @param [Hash] options
   # @option options [Boolean] :redirected Do not follow further Webfinger redirects
   # @option options [Boolean] :skip_webfinger Do not attempt any webfinger query or refreshing account data
+  # @option options [Boolean] :skip_cache Get the latest data from origin even if cache is not due to update yet
+  # @option options [Boolean] :suppress_errors When failing, return nil instead of raising an error
   # @return [Account]
   def call(uri, options = {})
     return if uri.blank?
@@ -52,15 +53,15 @@ class ResolveAccountService < BaseService
     # either needs to be created, or updated from fresh data
 
     fetch_account!
-  rescue Webfinger::Error, Oj::ParseError => e
+  rescue Webfinger::Error => e
     Rails.logger.debug "Webfinger query for #{@uri} failed: #{e}"
-    nil
+    raise unless @options[:suppress_errors]
   end
 
   private
 
   def process_options!(uri, options)
-    @options = options
+    @options = { suppress_errors: true }.merge(options)
 
     if uri.is_a?(Account)
       @account  = uri
@@ -96,7 +97,7 @@ class ResolveAccountService < BaseService
     @username, @domain = split_acct(@webfinger.subject)
 
     unless confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero?
-      raise Webfinger::RedirectError, "The URI #{uri} tries to hijack #{@username}@#{@domain}"
+      raise Webfinger::RedirectError, "Too many webfinger redirects for URI #{uri} (stopped at #{@username}@#{@domain})"
     end
   rescue Webfinger::GoneError
     @gone = true
@@ -110,7 +111,7 @@ class ResolveAccountService < BaseService
     return unless activitypub_ready?
 
     with_lock("resolve:#{@username}@#{@domain}") do
-      @account = ActivityPub::FetchRemoteAccountService.new.call(actor_url)
+      @account = ActivityPub::FetchRemoteAccountService.new.call(actor_url, suppress_errors: @options[:suppress_errors])
     end
 
     @account
@@ -120,7 +121,7 @@ class ResolveAccountService < BaseService
     return false if @options[:check_delivery_availability] && !DeliveryFailureTracker.available?(@domain)
     return false if @options[:skip_webfinger]
 
-    @account.nil? || @account.possibly_stale?
+    @options[:skip_cache] || @account.nil? || @account.possibly_stale?
   end
 
   def activitypub_ready?
diff --git a/app/services/resolve_url_service.rb b/app/services/resolve_url_service.rb
index e2c745673..52f35daf3 100644
--- a/app/services/resolve_url_service.rb
+++ b/app/services/resolve_url_service.rb
@@ -4,6 +4,8 @@ class ResolveURLService < BaseService
   include JsonLdHelper
   include Authorization
 
+  USERNAME_STATUS_RE = %r{/@(?<username>#{Account::USERNAME_RE})/(?<status_id>[0-9]+)\Z}
+
   def call(url, on_behalf_of: nil)
     @url          = url
     @on_behalf_of = on_behalf_of
@@ -20,8 +22,8 @@ class ResolveURLService < BaseService
   private
 
   def process_url
-    if equals_or_includes_any?(type, ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES)
-      ActivityPub::FetchRemoteAccountService.new.call(resource_url, prefetched_body: body)
+    if equals_or_includes_any?(type, ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES)
+      ActivityPub::FetchRemoteActorService.new.call(resource_url, prefetched_body: body)
     elsif equals_or_includes_any?(type, ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES)
       status = FetchRemoteStatusService.new.call(resource_url, body)
       authorize_with @on_behalf_of, status, :show? unless status.nil?
@@ -43,7 +45,7 @@ class ResolveURLService < BaseService
 
     # We don't have an index on `url`, so try guessing the `uri` from `url`
     parsed_url = Addressable::URI.parse(@url)
-    parsed_url.path.match(%r{/@(?<username>#{Account::USERNAME_RE})/(?<status_id>[0-9]+)\Z}) do |matched|
+    parsed_url.path.match(USERNAME_STATUS_RE) do |matched|
       parsed_url.path = "/users/#{matched[:username]}/statuses/#{matched[:status_id]}"
       scope = scope.or(Status.where(uri: parsed_url.to_s, url: @url))
     end
diff --git a/app/services/translate_status_service.rb b/app/services/translate_status_service.rb
new file mode 100644
index 000000000..539a0d9db
--- /dev/null
+++ b/app/services/translate_status_service.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class TranslateStatusService < BaseService
+  CACHE_TTL = 1.day.freeze
+
+  include FormattingHelper
+
+  def call(status, target_language)
+    raise Mastodon::NotPermittedError unless status.public_visibility? || status.unlisted_visibility?
+
+    @status = status
+    @content = status_content_format(@status)
+    @target_language = target_language
+
+    Rails.cache.fetch("translations/#{@status.language}/#{@target_language}/#{content_hash}", expires_in: CACHE_TTL) { translation_backend.translate(@content, @status.language, @target_language) }
+  end
+
+  private
+
+  def translation_backend
+    TranslationService.configured
+  end
+
+  def content_hash
+    Digest::SHA256.base64digest(@content)
+  end
+end
diff --git a/app/services/update_account_service.rb b/app/services/update_account_service.rb
index 77f794e17..71976ab00 100644
--- a/app/services/update_account_service.rb
+++ b/app/services/update_account_service.rb
@@ -28,7 +28,7 @@ class UpdateAccountService < BaseService
   end
 
   def check_links(account)
-    VerifyAccountLinksWorker.perform_async(account.id)
+    VerifyAccountLinksWorker.perform_async(account.id) if account.fields.any?(&:requires_verification?)
   end
 
   def process_hashtags(account)