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.rb26
-rw-r--r--app/services/authorize_follow_service.rb40
-rw-r--r--app/services/block_domain_service.rb10
-rw-r--r--app/services/block_service.rb26
-rw-r--r--app/services/concerns/stream_entry_renderer.rb8
-rw-r--r--app/services/fan_out_on_write_service.rb30
-rw-r--r--app/services/favourite_service.rb32
-rw-r--r--app/services/fetch_atom_service.rb4
-rw-r--r--app/services/fetch_remote_account_service.rb13
-rw-r--r--app/services/fetch_remote_resource_service.rb18
-rw-r--r--app/services/fetch_remote_status_service.rb9
-rw-r--r--app/services/follow_service.rb68
-rw-r--r--app/services/mute_service.rb23
-rw-r--r--app/services/notify_service.rb2
-rw-r--r--app/services/post_status_service.rb15
-rw-r--r--app/services/precompute_feed_service.rb10
-rw-r--r--app/services/process_feed_service.rb41
-rw-r--r--app/services/process_interaction_service.rb32
-rw-r--r--app/services/process_mentions_service.rb8
-rw-r--r--app/services/pubsubhubbub/subscribe_service.rb5
-rw-r--r--app/services/reblog_service.rb12
-rw-r--r--app/services/reject_follow_service.rb40
-rw-r--r--app/services/remove_status_service.rb10
-rw-r--r--app/services/search_service.rb25
-rw-r--r--app/services/send_interaction_service.rb19
-rw-r--r--app/services/unblock_service.rb24
-rw-r--r--app/services/unfavourite_service.rb30
-rw-r--r--app/services/unfollow_service.rb27
-rw-r--r--app/services/unmute_service.rb11
-rw-r--r--app/services/update_remote_profile_service.rb2
30 files changed, 509 insertions, 111 deletions
diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb
new file mode 100644
index 000000000..f55439dcb
--- /dev/null
+++ b/app/services/account_search_service.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class AccountSearchService < BaseService
+  def call(query, limit, resolve = false, account = nil)
+    return [] if query.blank? || query.start_with?('#')
+
+    username, domain = query.gsub(/\A@/, '').split('@')
+    domain = nil if TagManager.instance.local_domain?(domain)
+
+    if domain.nil?
+      exact_match = Account.find_local(username)
+      results     = account.nil? ? Account.search_for(username, limit) : Account.advanced_search_for(username, account, limit)
+    else
+      exact_match = Account.find_remote(username, domain)
+      results     = account.nil? ? Account.search_for("#{username} #{domain}", limit) : Account.advanced_search_for("#{username} #{domain}", account, limit)
+    end
+
+    results = [exact_match] + results.reject { |a| a.id == exact_match.id } if exact_match
+
+    if resolve && !exact_match && !domain.nil?
+      results = [FollowRemoteAccountService.new.call("#{username}@#{domain}")]
+    end
+
+    results
+  end
+end
diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb
new file mode 100644
index 000000000..ac465bdb2
--- /dev/null
+++ b/app/services/authorize_follow_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class AuthorizeFollowService < BaseService
+  def call(source_account, target_account)
+    follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
+    follow_request.authorize!
+    NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local?
+  end
+
+  private
+
+  def build_xml(follow_request)
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, Time.now.utc, follow_request.id, 'FollowRequest'
+        title xml, "#{follow_request.target_account.acct} authorizes follow request by #{follow_request.account.acct}"
+
+        author(xml) do
+          include_author xml, follow_request.target_account
+        end
+
+        object_type xml, :activity
+        verb xml, :authorize
+
+        target(xml) do
+          author(xml) do
+            include_author xml, follow_request.account
+          end
+
+          object_type xml, :activity
+          verb xml, :request_friend
+
+          target(xml) do
+            include_author xml, follow_request.target_account
+          end
+        end
+      end
+    end.to_xml
+  end
+end
diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb
index 9518b1fcf..6c131bd34 100644
--- a/app/services/block_domain_service.rb
+++ b/app/services/block_domain_service.rb
@@ -1,13 +1,11 @@
 # frozen_string_literal: true
 
 class BlockDomainService < BaseService
-  def call(domain, severity)
-    DomainBlock.where(domain: domain).first_or_create!(domain: domain, severity: severity)
-
-    if severity == :silence
-      Account.where(domain: domain).update_all(silenced: true)
+  def call(domain_block)
+    if domain_block.silence?
+      Account.where(domain: domain_block.domain).update_all(silenced: true)
     else
-      Account.where(domain: domain).find_each do |account|
+      Account.where(domain: domain_block.domain).find_each do |account|
         account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed?
         SuspendAccountService.new.call(account)
       end
diff --git a/app/services/block_service.rb b/app/services/block_service.rb
index e04b6cc39..bd914d8be 100644
--- a/app/services/block_service.rb
+++ b/app/services/block_service.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class BlockService < BaseService
+  include StreamEntryRenderer
+
   def call(account, target_account)
     return if account.id == target_account.id
 
@@ -10,6 +12,28 @@ class BlockService < BaseService
     block = account.block!(target_account)
 
     BlockWorker.perform_async(account.id, target_account.id)
-    NotificationWorker.perform_async(block.stream_entry.id, target_account.id) unless target_account.local?
+    NotificationWorker.perform_async(build_xml(block), account.id, target_account.id) unless target_account.local?
+  end
+
+  private
+
+  def build_xml(block)
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, block.created_at, block.id, 'Block'
+        title xml, "#{block.account.acct} no longer wishes to interact with #{block.target_account.acct}"
+
+        author(xml) do
+          include_author xml, block.account
+        end
+
+        object_type xml, :activity
+        verb xml, :block
+
+        target(xml) do
+          include_author xml, block.target_account
+        end
+      end
+    end.to_xml
   end
 end
diff --git a/app/services/concerns/stream_entry_renderer.rb b/app/services/concerns/stream_entry_renderer.rb
new file mode 100644
index 000000000..a4255daea
--- /dev/null
+++ b/app/services/concerns/stream_entry_renderer.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module StreamEntryRenderer
+  def stream_entry_to_xml(stream_entry)
+    renderer = StreamEntriesController.renderer.new(method: 'get', http_host: Rails.configuration.x.local_domain, https: Rails.configuration.x.use_https)
+    renderer.render(:show, assigns: { stream_entry: stream_entry }, formats: [:atom])
+  end
+end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 71f6cbca1..42222c25b 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -4,8 +4,15 @@ class FanOutOnWriteService < BaseService
   # Push a status into home and mentions feeds
   # @param [Status] status
   def call(status)
+    raise Mastodon::RaceConditionError if status.visibility.nil?
+
     deliver_to_self(status) if status.account.local?
-    deliver_to_followers(status)
+
+    if status.direct_visibility?
+      deliver_to_mentioned_followers(status)
+    else
+      deliver_to_followers(status)
+    end
 
     return if status.account.silenced? || !status.public_visibility? || status.reblog?
 
@@ -26,9 +33,18 @@ class FanOutOnWriteService < BaseService
   def deliver_to_followers(status)
     Rails.logger.debug "Delivering status #{status.id} to followers"
 
-    status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', 14.days.ago).find_each do |follower|
-      next if FeedManager.instance.filter?(:home, status, follower)
-      FeedManager.instance.push(:home, follower, status)
+    status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', 14.days.ago).select(:id).find_each do |follower|
+      FeedInsertWorker.perform_async(status.id, follower.id)
+    end
+  end
+
+  def deliver_to_mentioned_followers(status)
+    Rails.logger.debug "Delivering status #{status.id} to mentioned followers"
+
+    status.mentions.includes(:account).each do |mention|
+      mentioned_account = mention.account
+      next if !mentioned_account.local? || !mentioned_account.following?(status.account) || FeedManager.instance.filter?(:home, status, mention.account_id)
+      FeedManager.instance.push(:home, mentioned_account, status)
     end
   end
 
@@ -37,9 +53,9 @@ class FanOutOnWriteService < BaseService
 
     payload = FeedManager.instance.inline_render(nil, 'api/v1/statuses/show', status)
 
-    status.tags.find_each do |tag|
-      FeedManager.instance.broadcast("hashtag:#{tag.name}", event: 'update', payload: payload)
-      FeedManager.instance.broadcast("hashtag:#{tag.name}:local", event: 'update', payload: payload) if status.account.local?
+    status.tags.pluck(:name).each do |hashtag|
+      FeedManager.instance.broadcast("hashtag:#{hashtag}", event: 'update', payload: payload)
+      FeedManager.instance.broadcast("hashtag:#{hashtag}:local", event: 'update', payload: payload) if status.account.local?
     end
   end
 
diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb
index d5fbd29e9..5cc96403c 100644
--- a/app/services/favourite_service.rb
+++ b/app/services/favourite_service.rb
@@ -6,18 +6,42 @@ class FavouriteService < BaseService
   # @param [Status] status
   # @return [Favourite]
   def call(account, status)
-    raise Mastodon::NotPermitted unless status.permitted?(account)
+    raise Mastodon::NotPermittedError unless status.permitted?(account)
 
     favourite = Favourite.create!(account: account, status: status)
 
-    Pubsubhubbub::DistributionWorker.perform_async(favourite.stream_entry.id)
-
     if status.local?
       NotifyService.new.call(favourite.status.account, favourite)
     else
-      NotificationWorker.perform_async(favourite.stream_entry.id, status.account_id)
+      NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id)
     end
 
     favourite
   end
+
+  private
+
+  def build_xml(favourite)
+    description = "#{favourite.account.acct} favourited a status by #{favourite.status.account.acct}"
+
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, favourite.created_at, favourite.id, 'Favourite'
+        title xml, description
+        content xml, description
+
+        author(xml) do
+          include_author xml, favourite.account
+        end
+
+        object_type xml, :activity
+        verb xml, :favorite
+        in_reply_to xml, TagManager.instance.uri_for(favourite.status), TagManager.instance.url_for(favourite.status)
+
+        target(xml) do
+          include_target xml, favourite.status
+        end
+      end
+    end.to_xml
+  end
 end
diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb
index 98ee1db84..c3dad1eb9 100644
--- a/app/services/fetch_atom_service.rb
+++ b/app/services/fetch_atom_service.rb
@@ -2,6 +2,8 @@
 
 class FetchAtomService < BaseService
   def call(url)
+    return if url.blank?
+
     response = http_client.head(url)
 
     Rails.logger.debug "Remote status HEAD request returned code #{response.code}"
@@ -45,6 +47,6 @@ class FetchAtomService < BaseService
   end
 
   def http_client
-    HTTP.timeout(:per_operation, write: 20, connect: 20, read: 50).follow
+    HTTP.timeout(:per_operation, write: 10, connect: 10, read: 10).follow
   end
 end
diff --git a/app/services/fetch_remote_account_service.rb b/app/services/fetch_remote_account_service.rb
index 3c3694a65..6a6a696d6 100644
--- a/app/services/fetch_remote_account_service.rb
+++ b/app/services/fetch_remote_account_service.rb
@@ -1,8 +1,13 @@
 # frozen_string_literal: true
 
 class FetchRemoteAccountService < BaseService
-  def call(url)
-    atom_url, body = FetchAtomService.new.call(url)
+  def call(url, prefetched_body = nil)
+    if prefetched_body.nil?
+      atom_url, body = FetchAtomService.new.call(url)
+    else
+      atom_url = url
+      body     = prefetched_body
+    end
 
     return nil if atom_url.nil?
     process_atom(atom_url, body)
@@ -22,7 +27,9 @@ class FetchRemoteAccountService < BaseService
 
     Rails.logger.debug "Going to webfinger #{username}@#{domain}"
 
-    return FollowRemoteAccountService.new.call("#{username}@#{domain}")
+    account = FollowRemoteAccountService.new.call("#{username}@#{domain}")
+    UpdateRemoteProfileService.new.call(xml, account) unless account.nil?
+    account
   rescue TypeError
     Rails.logger.debug "Unparseable URL given: #{url}"
     nil
diff --git a/app/services/fetch_remote_resource_service.rb b/app/services/fetch_remote_resource_service.rb
new file mode 100644
index 000000000..2185ceb20
--- /dev/null
+++ b/app/services/fetch_remote_resource_service.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class FetchRemoteResourceService < BaseService
+  def call(url)
+    atom_url, body = FetchAtomService.new.call(url)
+
+    return nil if atom_url.nil?
+
+    xml = Nokogiri::XML(body)
+    xml.encoding = 'utf-8'
+
+    if xml.root.name == 'feed'
+      FetchRemoteAccountService.new.call(atom_url, body)
+    elsif xml.root.name == 'entry'
+      FetchRemoteStatusService.new.call(atom_url, body)
+    end
+  end
+end
diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb
index 7063231e4..e2d185723 100644
--- a/app/services/fetch_remote_status_service.rb
+++ b/app/services/fetch_remote_status_service.rb
@@ -1,8 +1,13 @@
 # frozen_string_literal: true
 
 class FetchRemoteStatusService < BaseService
-  def call(url)
-    atom_url, body = FetchAtomService.new.call(url)
+  def call(url, prefetched_body = nil)
+    if prefetched_body.nil?
+      atom_url, body = FetchAtomService.new.call(url)
+    else
+      atom_url = url
+      body     = prefetched_body
+    end
 
     return nil if atom_url.nil?
     process_atom(atom_url, body)
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index 9f34cb6ac..17b3b2542 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -1,14 +1,16 @@
 # frozen_string_literal: true
 
 class FollowService < BaseService
+  include StreamEntryRenderer
+
   # Follow a remote user, notify remote user about the follow
   # @param [Account] source_account From which to follow
   # @param [String] uri User URI to follow in the form of username@domain
   def call(source_account, uri)
-    target_account = follow_remote_account_service.call(uri)
+    target_account = FollowRemoteAccountService.new.call(uri)
 
     raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
-    raise Mastodon::NotPermitted       if target_account.blocking?(source_account) || source_account.blocking?(target_account)
+    raise Mastodon::NotPermittedError       if target_account.blocking?(source_account) || source_account.blocking?(target_account)
 
     if target_account.locked?
       request_follow(source_account, target_account)
@@ -20,10 +22,14 @@ class FollowService < BaseService
   private
 
   def request_follow(source_account, target_account)
-    return unless target_account.local?
-
     follow_request = FollowRequest.create!(account: source_account, target_account: target_account)
-    NotifyService.new.call(target_account, follow_request)
+
+    if target_account.local?
+      NotifyService.new.call(target_account, follow_request)
+    else
+      NotificationWorker.perform_async(build_follow_request_xml(follow_request), source_account.id, target_account.id)
+      AfterRemoteFollowRequestWorker.perform_async(follow_request.id)
+    end
 
     follow_request
   end
@@ -34,12 +40,12 @@ class FollowService < BaseService
     if target_account.local?
       NotifyService.new.call(target_account, follow)
     else
-      subscribe_service.call(target_account)
-      NotificationWorker.perform_async(follow.stream_entry.id, target_account.id)
+      SubscribeService.new.call(target_account) unless target_account.subscribed?
+      NotificationWorker.perform_async(build_follow_xml(follow), source_account.id, target_account.id)
+      AfterRemoteFollowWorker.perform_async(follow.id)
     end
 
     MergeWorker.perform_async(target_account.id, source_account.id)
-    Pubsubhubbub::DistributionWorker.perform_async(follow.stream_entry.id)
 
     follow
   end
@@ -48,11 +54,49 @@ class FollowService < BaseService
     Redis.current
   end
 
-  def follow_remote_account_service
-    @follow_remote_account_service ||= FollowRemoteAccountService.new
+  def build_follow_request_xml(follow_request)
+    description = "#{follow_request.account.acct} requested to follow #{follow_request.target_account.acct}"
+
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, follow_request.created_at, follow_request.id, 'FollowRequest'
+        title xml, description
+        content xml, description
+
+        author(xml) do
+          include_author xml, follow_request.account
+        end
+
+        object_type xml, :activity
+        verb xml, :request_friend
+
+        target(xml) do
+          include_author xml, follow_request.target_account
+        end
+      end
+    end.to_xml
   end
 
-  def subscribe_service
-    @subscribe_service ||= SubscribeService.new
+  def build_follow_xml(follow)
+    description = "#{follow.account.acct} started following #{follow.target_account.acct}"
+
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, follow.created_at, follow.id, 'Follow'
+        title xml, description
+        content xml, description
+
+        author(xml) do
+          include_author xml, follow.account
+        end
+
+        object_type xml, :activity
+        verb xml, :follow
+
+        target(xml) do
+          include_author xml, follow.target_account
+        end
+      end
+    end.to_xml
   end
 end
diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb
new file mode 100644
index 000000000..0050cfc8d
--- /dev/null
+++ b/app/services/mute_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class MuteService < BaseService
+  def call(account, target_account)
+    return if account.id == target_account.id
+    clear_home_timeline(account, target_account)
+    account.mute!(target_account)
+  end
+
+  private
+
+  def clear_home_timeline(account, target_account)
+    home_key = FeedManager.instance.key(:home, account.id)
+
+    target_account.statuses.select('id').find_each do |status|
+      redis.zrem(home_key, status.id)
+    end
+  end
+
+  def redis
+    Redis.current
+  end
+end
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index 942cd9d21..24486f220 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -17,7 +17,7 @@ class NotifyService < BaseService
   private
 
   def blocked_mention?
-    FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient)
+    FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient.id)
   end
 
   def blocked_favourite?
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 979941c84..b8179f7dc 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -13,6 +13,7 @@ class PostStatusService < BaseService
   # @option [Doorkeeper::Application] :application
   # @return [Status]
   def call(account, text, in_reply_to = nil, options = {})
+    media  = validate_media!(options[:media_ids])
     status = account.statuses.create!(text: text,
                                       thread: in_reply_to,
                                       sensitive: options[:sensitive],
@@ -20,7 +21,7 @@ class PostStatusService < BaseService
                                       visibility: options[:visibility],
                                       application: options[:application])
 
-    attach_media(status, options[:media_ids])
+    attach_media(status, media)
     process_mentions_service.call(status)
     process_hashtags_service.call(status)
 
@@ -33,10 +34,20 @@ class PostStatusService < BaseService
 
   private
 
-  def attach_media(status, media_ids)
+  def validate_media!(media_ids)
     return if media_ids.nil? || !media_ids.is_a?(Enumerable)
 
+    raise Mastodon::ValidationError, 'Cannot attach more than 4 files' if media_ids.size > 4
+
     media = MediaAttachment.where(status_id: nil).where(id: media_ids.take(4).map(&:to_i))
+
+    raise Mastodon::ValidationError, 'Cannot attach a video to a toot that already contains images' if media.size > 1 && media.find(&:video?)
+
+    media
+  end
+
+  def attach_media(status, media)
+    return if media.nil?
     media.update(status_id: status.id)
   end
 
diff --git a/app/services/precompute_feed_service.rb b/app/services/precompute_feed_service.rb
index 54d11b631..07dcb81da 100644
--- a/app/services/precompute_feed_service.rb
+++ b/app/services/precompute_feed_service.rb
@@ -4,10 +4,12 @@ class PrecomputeFeedService < BaseService
   # Fill up a user's home/mentions feed from DB and return a subset
   # @param [Symbol] type :home or :mentions
   # @param [Account] account
-  def call(type, account)
-    Status.send("as_#{type}_timeline", account).limit(FeedManager::MAX_ITEMS).each do |status|
-      next if FeedManager.instance.filter?(type, status, account)
-      redis.zadd(FeedManager.instance.key(type, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id)
+  def call(_, account)
+    redis.pipelined do
+      Status.as_home_timeline(account).limit(FeedManager::MAX_ITEMS / 4).each do |status|
+        next if status.direct_visibility? || FeedManager.instance.filter?(:home, status, account.id)
+        redis.zadd(FeedManager.instance.key(:home, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id)
+      end
     end
   end
 
diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb
index c411e3e82..69911abc5 100644
--- a/app/services/process_feed_service.rb
+++ b/app/services/process_feed_service.rb
@@ -61,12 +61,25 @@ class ProcessFeedService < BaseService
 
       status.save!
 
-      NotifyService.new.call(status.reblog.account, status) if status.reblog? && status.reblog.account.local?
+      notify_about_mentions!(status) unless status.reblog?
+      notify_about_reblog!(status) if status.reblog? && status.reblog.account.local?
       Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution"
       DistributionWorker.perform_async(status.id)
       status
     end
 
+    def notify_about_mentions!(status)
+      status.mentions.includes(:account).each do |mention|
+        mentioned_account = mention.account
+        next unless mentioned_account.local?
+        NotifyService.new.call(mentioned_account, mention)
+      end
+    end
+
+    def notify_about_reblog!(status)
+      NotifyService.new.call(status.reblog.account, status)
+    end
+
     def delete_status
       Rails.logger.debug "Deleting remote status #{id}"
       status = Status.find_by(uri: id)
@@ -106,7 +119,8 @@ class ProcessFeedService < BaseService
         text: content(entry),
         spoiler_text: content_warning(entry),
         created_at: published(entry),
-        reply: thread?(entry)
+        reply: thread?(entry),
+        visibility: visibility_scope(entry)
       )
 
       if thread?(entry)
@@ -144,15 +158,9 @@ class ProcessFeedService < BaseService
 
     def mentions_from_xml(parent, xml)
       processed_account_ids = []
-      public_visibility     = false
 
       xml.xpath('./xmlns:link[@rel="mentioned"]', xmlns: TagManager::XMLNS).each do |link|
-        if link['ostatus:object-type'] == TagManager::TYPES[:collection] && link['href'] == TagManager::COLLECTIONS[:public]
-          public_visibility = true
-          next
-        elsif link['ostatus:object-type'] == TagManager::TYPES[:group]
-          next
-        end
+        next if [TagManager::TYPES[:group], TagManager::TYPES[:collection]].include? link['ostatus:object-type']
 
         url = Addressable::URI.parse(link['href'])
 
@@ -164,17 +172,11 @@ class ProcessFeedService < BaseService
 
         next if mentioned_account.nil? || processed_account_ids.include?(mentioned_account.id)
 
-        mention = mentioned_account.mentions.where(status: parent).first_or_create(status: parent)
-
-        # Notify local user
-        NotifyService.new.call(mentioned_account, mention) if mentioned_account.local?
+        mentioned_account.mentions.where(status: parent).first_or_create(status: parent)
 
         # So we can skip duplicate mentions
         processed_account_ids << mentioned_account.id
       end
-
-      parent.visibility = public_visibility ? :public : :unlisted
-      parent.save!
     end
 
     def hashtags_from_xml(parent, xml)
@@ -189,6 +191,9 @@ class ProcessFeedService < BaseService
         next unless link['href']
 
         media = MediaAttachment.where(status: parent, remote_url: link['href']).first_or_initialize(account: parent.account, status: parent, remote_url: link['href'])
+        parsed_url = URI.parse(link['href'])
+
+        next if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty?
 
         begin
           media.file_remote_url = link['href']
@@ -230,6 +235,10 @@ class ProcessFeedService < BaseService
       xml.at_xpath('./xmlns:summary', xmlns: TagManager::XMLNS)&.content || ''
     end
 
+    def visibility_scope(xml = @xml)
+      xml.at_xpath('./mastodon:scope', mastodon: TagManager::MTDN_XMLNS)&.content&.to_sym || :public
+    end
+
     def published(xml = @xml)
       xml.at_xpath('./xmlns:published', xmlns: TagManager::XMLNS).content
     end
diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb
index 5f91e3127..d5f7b4b3c 100644
--- a/app/services/process_interaction_service.rb
+++ b/app/services/process_interaction_service.rb
@@ -29,10 +29,18 @@ class ProcessInteractionService < BaseService
       case verb(xml)
       when :follow
         follow!(account, target_account) unless target_account.locked? || target_account.blocking?(account)
+      when :request_friend
+        follow_request!(account, target_account) unless !target_account.locked? || target_account.blocking?(account)
+      when :authorize
+        authorize_follow_request!(account, target_account)
+      when :reject
+        reject_follow_request!(account, target_account)
       when :unfollow
         unfollow!(account, target_account)
       when :favorite
         favourite!(xml, account)
+      when :unfavorite
+        unfavourite!(xml, account)
       when :post
         add_post!(body, account) if mentions_account?(xml, target_account)
       when :share
@@ -56,7 +64,7 @@ class ProcessInteractionService < BaseService
   end
 
   def mentions_account?(xml, account)
-    xml.xpath('/xmlns:entry/xmlns:link[@rel="mentioned"]', xmlns: TagManager::XMLNS).each { |mention_link| return true if mention_link.attribute('href').value == TagManager.instance.url_for(account) }
+    xml.xpath('/xmlns:entry/xmlns:link[@rel="mentioned"]', xmlns: TagManager::XMLNS).each { |mention_link| return true if [TagManager.instance.uri_for(account), TagManager.instance.url_for(account)].include?(mention_link.attribute('href').value) }
     false
   end
 
@@ -72,6 +80,22 @@ class ProcessInteractionService < BaseService
     NotifyService.new.call(target_account, follow)
   end
 
+  def follow_request!(account, target_account)
+    follow_request = FollowRequest.create!(account: account, target_account: target_account)
+    NotifyService.new.call(target_account, follow_request)
+  end
+
+  def authorize_follow_request!(account, target_account)
+    follow_request = FollowRequest.find_by(account: target_account, target_account: account)
+    follow_request&.authorize!
+    SubscribeService.new.call(account) unless account.subscribed?
+  end
+
+  def reject_follow_request!(account, target_account)
+    follow_request = FollowRequest.find_by(account: target_account, target_account: account)
+    follow_request&.reject!
+  end
+
   def unfollow!(account, target_account)
     account.unfollow!(target_account)
   end
@@ -99,6 +123,12 @@ class ProcessInteractionService < BaseService
     NotifyService.new.call(current_status.account, favourite)
   end
 
+  def unfavourite!(xml, from_account)
+    current_status = status(xml)
+    favourite = current_status.favourites.where(account: from_account).first
+    favourite&.destroy
+  end
+
   def add_post!(body, account)
     process_feed_service.call(body, account)
   end
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index 72568e702..aa0a4d71b 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class ProcessMentionsService < BaseService
+  include StreamEntryRenderer
+
   # Scan status for mentions and fetch remote mentioned users, create
   # local mention pointers, send Salmon notifications to mentioned
   # remote users
@@ -25,15 +27,13 @@ class ProcessMentionsService < BaseService
       mentioned_account.mentions.where(status: status).first_or_create(status: status)
     end
 
-    status.mentions.each do |mention|
+    status.mentions.includes(:account).each do |mention|
       mentioned_account = mention.account
 
-      next if status.private_visibility? && (!mentioned_account.following?(status.account) || !mentioned_account.local?)
-
       if mentioned_account.local?
         NotifyService.new.call(mentioned_account, mention)
       else
-        NotificationWorker.perform_async(status.stream_entry.id, mentioned_account.id)
+        NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id)
       end
     end
   end
diff --git a/app/services/pubsubhubbub/subscribe_service.rb b/app/services/pubsubhubbub/subscribe_service.rb
index 343376d77..bf36e3fa6 100644
--- a/app/services/pubsubhubbub/subscribe_service.rb
+++ b/app/services/pubsubhubbub/subscribe_service.rb
@@ -2,8 +2,9 @@
 
 class Pubsubhubbub::SubscribeService < BaseService
   def call(account, callback, secret, lease_seconds)
-    return ['Invalid topic URL', 422] if account.nil?
-    return ['Invalid callback URL', 422] unless !callback.blank? && callback =~ /\A#{URI.regexp(%w(http https))}\z/
+    return ['Invalid topic URL',        422] if account.nil?
+    return ['Invalid callback URL',     422] unless !callback.blank? && callback =~ /\A#{URI.regexp(%w(http https))}\z/
+    return ['Callback URL not allowed', 403] if DomainBlock.blocked?(Addressable::URI.parse(callback).host)
 
     subscription = Subscription.where(account: account, callback_url: callback).first_or_create!(account: account, callback_url: callback)
     Pubsubhubbub::ConfirmationWorker.perform_async(subscription.id, 'subscribe', secret, lease_seconds)
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 4ea0dbf6c..11446ce28 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class ReblogService < BaseService
+  include StreamEntryRenderer
+
   # Reblog a status and notify its remote author
   # @param [Account] account Account to reblog from
   # @param [Status] reblogged_status Status to be reblogged
@@ -8,7 +10,7 @@ class ReblogService < BaseService
   def call(account, reblogged_status)
     reblogged_status = reblogged_status.reblog if reblogged_status.reblog?
 
-    raise Mastodon::NotPermitted if reblogged_status.private_visibility? || !reblogged_status.permitted?(account)
+    raise Mastodon::NotPermittedError if reblogged_status.direct_visibility? || reblogged_status.private_visibility? || !reblogged_status.permitted?(account)
 
     reblog = account.statuses.create!(reblog: reblogged_status, text: '')
 
@@ -18,15 +20,9 @@ class ReblogService < BaseService
     if reblogged_status.local?
       NotifyService.new.call(reblog.reblog.account, reblog)
     else
-      NotificationWorker.perform_async(reblog.stream_entry.id, reblog.reblog.account_id)
+      NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), account.id, reblog.reblog.account_id)
     end
 
     reblog
   end
-
-  private
-
-  def send_interaction_service
-    @send_interaction_service ||= SendInteractionService.new
-  end
 end
diff --git a/app/services/reject_follow_service.rb b/app/services/reject_follow_service.rb
new file mode 100644
index 000000000..1b03d62e6
--- /dev/null
+++ b/app/services/reject_follow_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class RejectFollowService < BaseService
+  def call(source_account, target_account)
+    follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
+    follow_request.reject!
+    NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local?
+  end
+
+  private
+
+  def build_xml(follow_request)
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, Time.now.utc, follow_request.id, 'FollowRequest'
+        title xml, "#{follow_request.target_account.acct} rejects follow request by #{follow_request.account.acct}"
+
+        author(xml) do
+          include_author xml, follow_request.target_account
+        end
+
+        object_type xml, :activity
+        verb xml, :reject
+
+        target(xml) do
+          author(xml) do
+            include_author xml, follow_request.account
+          end
+
+          object_type xml, :activity
+          verb xml, :request_friend
+
+          target(xml) do
+            include_author xml, follow_request.target_account
+          end
+        end
+      end
+    end.to_xml
+  end
+end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 48e8dd3b8..cf1f432e4 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class RemoveStatusService < BaseService
+  include StreamEntryRenderer
+
   def call(status)
     remove_from_self(status) if status.account.local?
     remove_from_followers(status)
@@ -30,12 +32,16 @@ class RemoveStatusService < BaseService
   end
 
   def remove_from_mentioned(status)
+    notified_domains = []
+
     status.mentions.each do |mention|
       mentioned_account = mention.account
 
       if mentioned_account.local?
         unpush(:mentions, mentioned_account, status)
       else
+        next if notified_domains.include?(mentioned_account.domain)
+        notified_domains << mentioned_account.domain
         send_delete_salmon(mentioned_account, status)
       end
     end
@@ -43,7 +49,7 @@ class RemoveStatusService < BaseService
 
   def send_delete_salmon(account, status)
     return unless status.local?
-    NotificationWorker.perform_async(status.stream_entry.id, account.id)
+    NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, account.id)
   end
 
   def remove_reblogs(status)
@@ -53,7 +59,7 @@ class RemoveStatusService < BaseService
   end
 
   def unpush(type, receiver, status)
-    if status.reblog?
+    if status.reblog? && !redis.zscore(FeedManager.instance.key(type, receiver.id), status.reblog_of_id).nil?
       redis.zadd(FeedManager.instance.key(type, receiver.id), status.reblog_of_id, status.reblog_of_id)
     else
       redis.zremrangebyscore(FeedManager.instance.key(type, receiver.id), status.id, status.id)
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 04de8a134..e9745010b 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -1,24 +1,19 @@
 # frozen_string_literal: true
 
 class SearchService < BaseService
-  def call(query, limit, resolve = false)
-    return if query.blank? || query.start_with?('#')
+  def call(query, limit, resolve = false, account = nil)
+    results = { accounts: [], hashtags: [], statuses: [] }
 
-    username, domain = query.gsub(/\A@/, '').split('@')
+    return results if query.blank?
 
-    if domain.nil?
-      exact_match = Account.find_local(username)
-      results     = Account.search_for(username)
-    else
-      exact_match = Account.find_remote(username, domain)
-      results     = Account.search_for("#{username} #{domain}")
-    end
+    if query =~ /\Ahttps?:\/\//
+      resource = FetchRemoteResourceService.new.call(query)
 
-    results = results.limit(limit).to_a
-    results = [exact_match] + results.reject { |a| a.id == exact_match.id } if exact_match
-
-    if resolve && !exact_match && !domain.nil?
-      results = [FollowRemoteAccountService.new.call("#{username}@#{domain}")]
+      results[:accounts] << resource if resource.is_a?(Account)
+      results[:statuses] << resource if resource.is_a?(Status)
+    else
+      results[:accounts] = AccountSearchService.new.call(query, limit, resolve, account)
+      results[:hashtags] = Tag.search_for(query.gsub(/\A#/, ''), limit) unless query.start_with?('@')
     end
 
     results
diff --git a/app/services/send_interaction_service.rb b/app/services/send_interaction_service.rb
index 05a1e77e3..99113eeca 100644
--- a/app/services/send_interaction_service.rb
+++ b/app/services/send_interaction_service.rb
@@ -2,27 +2,16 @@
 
 class SendInteractionService < BaseService
   # Send an Atom representation of an interaction to a remote Salmon endpoint
-  # @param [StreamEntry] stream_entry
+  # @param [String] Entry XML
+  # @param [Account] source_account
   # @param [Account] target_account
-  def call(stream_entry, target_account)
-    envelope = salmon.pack(entry_xml(stream_entry), stream_entry.account.keypair)
+  def call(xml, source_account, target_account)
+    envelope = salmon.pack(xml, source_account.keypair)
     salmon.post(target_account.salmon_url, envelope)
   end
 
   private
 
-  def entry_xml(stream_entry)
-    Nokogiri::XML::Builder.new do |xml|
-      entry(xml, true) do
-        author(xml) do
-          include_author xml, stream_entry.account
-        end
-
-        include_entry xml, stream_entry
-      end
-    end.to_xml
-  end
-
   def salmon
     @salmon ||= OStatus2::Salmon.new
   end
diff --git a/app/services/unblock_service.rb b/app/services/unblock_service.rb
index f389364f9..c4f789f74 100644
--- a/app/services/unblock_service.rb
+++ b/app/services/unblock_service.rb
@@ -5,6 +5,28 @@ class UnblockService < BaseService
     return unless account.blocking?(target_account)
 
     unblock = account.unblock!(target_account)
-    NotificationWorker.perform_async(unblock.stream_entry.id, target_account.id) unless target_account.local?
+    NotificationWorker.perform_async(build_xml(unblock), account.id, target_account.id) unless target_account.local?
+  end
+
+  private
+
+  def build_xml(block)
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, Time.now.utc, block.id, 'Block'
+        title xml, "#{block.account.acct} no longer blocks #{block.target_account.acct}"
+
+        author(xml) do
+          include_author xml, block.account
+        end
+
+        object_type xml, :activity
+        verb xml, :unblock
+
+        target(xml) do
+          include_author xml, block.target_account
+        end
+      end
+    end.to_xml
   end
 end
diff --git a/app/services/unfavourite_service.rb b/app/services/unfavourite_service.rb
index de6e84e7d..5f0ba4254 100644
--- a/app/services/unfavourite_service.rb
+++ b/app/services/unfavourite_service.rb
@@ -5,10 +5,34 @@ class UnfavouriteService < BaseService
     favourite = Favourite.find_by!(account: account, status: status)
     favourite.destroy!
 
-    unless status.local?
-      NotificationWorker.perform_async(favourite.stream_entry.id, status.account_id)
-    end
+    NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id) unless status.local?
 
     favourite
   end
+
+  private
+
+  def build_xml(favourite)
+    description = "#{favourite.account.acct} no longer favourites a status by #{favourite.status.account.acct}"
+
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, Time.now.utc, favourite.id, 'Favourite'
+        title xml, description
+        content xml, description
+
+        author(xml) do
+          include_author xml, favourite.account
+        end
+
+        object_type xml, :activity
+        verb xml, :unfavorite
+        in_reply_to xml, TagManager.instance.uri_for(favourite.status), TagManager.instance.url_for(favourite.status)
+
+        target(xml) do
+          include_target xml, favourite.status
+        end
+      end
+    end.to_xml
+  end
 end
diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb
index f469793c1..3440da364 100644
--- a/app/services/unfollow_service.rb
+++ b/app/services/unfollow_service.rb
@@ -6,7 +6,32 @@ class UnfollowService < BaseService
   # @param [Account] target_account Which to unfollow
   def call(source_account, target_account)
     follow = source_account.unfollow!(target_account)
-    NotificationWorker.perform_async(follow.stream_entry.id, target_account.id) unless target_account.local?
+    NotificationWorker.perform_async(build_xml(follow), source_account.id, target_account.id) unless target_account.local?
     UnmergeWorker.perform_async(target_account.id, source_account.id)
   end
+
+  private
+
+  def build_xml(follow)
+    description = "#{follow.account.acct} is no longer following #{follow.target_account.acct}"
+
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, Time.now.utc, follow.id, 'Follow'
+        title xml, description
+        content xml, description
+
+        author(xml) do
+          include_author xml, follow.account
+        end
+
+        object_type xml, :activity
+        verb xml, :unfollow
+
+        target(xml) do
+          include_author xml, follow.target_account
+        end
+      end
+    end.to_xml
+  end
 end
diff --git a/app/services/unmute_service.rb b/app/services/unmute_service.rb
new file mode 100644
index 000000000..6aeea358f
--- /dev/null
+++ b/app/services/unmute_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class UnmuteService < BaseService
+  def call(account, target_account)
+    return unless account.muting?(target_account)
+
+    account.unmute!(target_account)
+
+    MergeWorker.perform_async(target_account.id, account.id) if account.following?(target_account)
+  end
+end
diff --git a/app/services/update_remote_profile_service.rb b/app/services/update_remote_profile_service.rb
index ad9c56540..74baa1cc5 100644
--- a/app/services/update_remote_profile_service.rb
+++ b/app/services/update_remote_profile_service.rb
@@ -10,9 +10,11 @@ class UpdateRemoteProfileService < BaseService
     unless author_xml.nil?
       account.display_name = author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).nil?
       account.note         = author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).nil?
+      account.locked       = author_xml.at_xpath('./mastodon:scope', mastodon: TagManager::MTDN_XMLNS)&.content == 'private'
 
       unless account.suspended? || DomainBlock.find_by(domain: account.domain)&.reject_media?
         account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'] unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS).nil? || author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'].blank?
+        account.header_remote_url = author_xml.at_xpath('./xmlns:link[@rel="header"]', xmlns: TagManager::XMLNS)['href'] unless author_xml.at_xpath('./xmlns:link[@rel="header"]', xmlns: TagManager::XMLNS).nil? || author_xml.at_xpath('./xmlns:link[@rel="header"]', xmlns: TagManager::XMLNS)['href'].blank?
       end
     end