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/activitypub/fetch_remote_account_service.rb57
-rw-r--r--app/services/activitypub/fetch_remote_key_service.rb47
-rw-r--r--app/services/activitypub/fetch_remote_status_service.rb48
-rw-r--r--app/services/activitypub/process_account_service.rb103
-rw-r--r--app/services/activitypub/process_collection_service.rb48
-rw-r--r--app/services/authorize_follow_service.rb30
-rw-r--r--app/services/batched_remove_status_service.rb67
-rw-r--r--app/services/block_domain_service.rb2
-rw-r--r--app/services/block_service.rb19
-rw-r--r--app/services/favourite_service.rb28
-rw-r--r--app/services/fetch_atom_service.rb84
-rw-r--r--app/services/fetch_link_card_service.rb100
-rw-r--r--app/services/fetch_remote_account_service.rb16
-rw-r--r--app/services/fetch_remote_resource_service.rb33
-rw-r--r--app/services/fetch_remote_status_service.rb16
-rw-r--r--app/services/follow_service.rb14
-rw-r--r--app/services/post_status_service.rb2
-rw-r--r--app/services/process_interaction_service.rb4
-rw-r--r--app/services/process_mentions_service.rb28
-rw-r--r--app/services/reblog_service.rb28
-rw-r--r--app/services/reject_follow_service.rb19
-rw-r--r--app/services/remove_status_service.rb51
-rw-r--r--app/services/resolve_remote_account_service.rb55
-rw-r--r--app/services/subscribe_service.rb4
-rw-r--r--app/services/unblock_service.rb19
-rw-r--r--app/services/unfavourite_service.rb22
-rw-r--r--app/services/unfollow_service.rb45
-rw-r--r--app/services/unsubscribe_service.rb2
-rw-r--r--app/services/update_account_service.rb21
29 files changed, 865 insertions, 147 deletions
diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb
new file mode 100644
index 000000000..3eeca585e
--- /dev/null
+++ b/app/services/activitypub/fetch_remote_account_service.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchRemoteAccountService < BaseService
+  include JsonLdHelper
+
+  # Should be called when uri has already been checked for locality
+  # Does a WebFinger roundtrip on each call
+  def call(uri, prefetched_json = nil)
+    @json = body_to_json(prefetched_json) || fetch_resource(uri)
+
+    return unless supported_context? && expected_type?
+
+    @uri      = @json['id']
+    @username = @json['preferredUsername']
+    @domain   = Addressable::URI.parse(uri).normalized_host
+
+    return unless verified_webfinger?
+
+    ActivityPub::ProcessAccountService.new.call(@username, @domain, @json)
+  rescue Oj::ParseError
+    nil
+  end
+
+  private
+
+  def verified_webfinger?
+    webfinger                            = Goldfinger.finger("acct:#{@username}@#{@domain}")
+    confirmed_username, confirmed_domain = split_acct(webfinger.subject)
+
+    return true if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
+
+    webfinger                            = Goldfinger.finger("acct:#{confirmed_username}@#{confirmed_domain}")
+    confirmed_username, confirmed_domain = split_acct(webfinger.subject)
+    self_reference                       = webfinger.link('self')
+
+    return false if self_reference&.href != @uri
+
+    @username = confirmed_username
+    @domain   = confirmed_domain
+
+    true
+  rescue Goldfinger::Error
+    false
+  end
+
+  def split_acct(acct)
+    acct.gsub(/\Aacct:/, '').split('@')
+  end
+
+  def supported_context?
+    super(@json)
+  end
+
+  def expected_type?
+    @json['type'] == 'Person'
+  end
+end
diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb
new file mode 100644
index 000000000..ebd64071e
--- /dev/null
+++ b/app/services/activitypub/fetch_remote_key_service.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchRemoteKeyService < BaseService
+  include JsonLdHelper
+
+  # Returns account that owns the key
+  def call(uri, prefetched_json = nil)
+    @json = body_to_json(prefetched_json) || fetch_resource(uri)
+
+    return unless supported_context?(@json) && expected_type?
+    return find_account(uri, @json) if person?
+
+    @owner = fetch_resource(owner_uri)
+
+    return unless supported_context?(@owner) && confirmed_owner?
+
+    find_account(owner_uri, @owner)
+  end
+
+  private
+
+  def find_account(uri, prefetched_json)
+    account   = ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
+    account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_json)
+    account
+  end
+
+  def expected_type?
+    person? || public_key?
+  end
+
+  def person?
+    @json['type'] == 'Person'
+  end
+
+  def public_key?
+    @json['publicKeyPem'].present? && @json['owner'].present?
+  end
+
+  def owner_uri
+    @owner_uri ||= value_or_id(@json['owner'])
+  end
+
+  def confirmed_owner?
+    @owner['type'] == 'Person' && value_or_id(@owner['publicKey']) == @json['id']
+  end
+end
diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb
new file mode 100644
index 000000000..68ca58d62
--- /dev/null
+++ b/app/services/activitypub/fetch_remote_status_service.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchRemoteStatusService < BaseService
+  include JsonLdHelper
+
+  # Should be called when uri has already been checked for locality
+  def call(uri, prefetched_json = nil)
+    @json = body_to_json(prefetched_json) || fetch_resource(uri)
+
+    return unless supported_context?
+
+    activity = activity_json
+    actor_id = value_or_id(activity['actor'])
+
+    return unless expected_type?(activity) && trustworthy_attribution?(uri, actor_id)
+
+    actor = ActivityPub::TagManager.instance.uri_to_resource(actor_id, Account)
+    actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id) if actor.nil?
+
+    ActivityPub::Activity.factory(activity, actor).perform
+  end
+
+  private
+
+  def activity_json
+    if %w(Note Article).include? @json['type']
+      {
+        'type'   => 'Create',
+        'actor'  => first_of_value(@json['attributedTo']),
+        'object' => @json,
+      }
+    else
+      @json
+    end
+  end
+
+  def trustworthy_attribution?(uri, attributed_to)
+    Addressable::URI.parse(uri).normalized_host.casecmp(Addressable::URI.parse(attributed_to).normalized_host).zero?
+  end
+
+  def supported_context?
+    super(@json)
+  end
+
+  def expected_type?(json)
+    %w(Create Announce).include? json['type']
+  end
+end
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
new file mode 100644
index 000000000..29eb1c2e1
--- /dev/null
+++ b/app/services/activitypub/process_account_service.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+class ActivityPub::ProcessAccountService < BaseService
+  include JsonLdHelper
+
+  # Should be called with confirmed valid JSON
+  # and WebFinger-resolved username and domain
+  def call(username, domain, json)
+    return if json['inbox'].blank?
+
+    @json     = json
+    @uri      = @json['id']
+    @username = username
+    @domain   = domain
+    @account  = Account.find_by(uri: @uri)
+
+    create_account  if @account.nil?
+    upgrade_account if @account.ostatus?
+    update_account
+
+    @account
+  rescue Oj::ParseError
+    nil
+  end
+
+  private
+
+  def create_account
+    @account = Account.new
+    @account.protocol    = :activitypub
+    @account.username    = @username
+    @account.domain      = @domain
+    @account.uri         = @uri
+    @account.suspended   = true if auto_suspend?
+    @account.silenced    = true if auto_silence?
+    @account.private_key = nil
+    @account.save!
+  end
+
+  def update_account
+    @account.last_webfingered_at = Time.now.utc
+    @account.protocol            = :activitypub
+    @account.inbox_url           = @json['inbox'] || ''
+    @account.outbox_url          = @json['outbox'] || ''
+    @account.shared_inbox_url    = (@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) || ''
+    @account.followers_url       = @json['followers'] || ''
+    @account.url                 = url || @uri
+    @account.display_name        = @json['name'] || ''
+    @account.note                = @json['summary'] || ''
+    @account.avatar_remote_url   = image_url('icon')
+    @account.header_remote_url   = image_url('image')
+    @account.public_key          = public_key || ''
+    @account.locked              = @json['manuallyApprovesFollowers'] || false
+    @account.save!
+  end
+
+  def upgrade_account
+    ActivityPub::PostUpgradeWorker.perform_async(@account.domain)
+  end
+
+  def image_url(key)
+    value = first_of_value(@json[key])
+
+    return if value.nil?
+    return value['url'] if value.is_a?(Hash)
+
+    image = fetch_resource(value)
+    image['url'] if image
+  end
+
+  def public_key
+    value = first_of_value(@json['publicKey'])
+
+    return if value.nil?
+    return value['publicKeyPem'] if value.is_a?(Hash)
+
+    key = fetch_resource(value)
+    key['publicKeyPem'] if key
+  end
+
+  def url
+    return if @json['url'].blank?
+
+    value = first_of_value(@json['url'])
+
+    return value if value.is_a?(String)
+
+    value['href']
+  end
+
+  def auto_suspend?
+    domain_block && domain_block.suspend?
+  end
+
+  def auto_silence?
+    domain_block && domain_block.silence?
+  end
+
+  def domain_block
+    return @domain_block if defined?(@domain_block)
+    @domain_block = DomainBlock.find_by(domain: @domain)
+  end
+end
diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb
new file mode 100644
index 000000000..bc04c50ba
--- /dev/null
+++ b/app/services/activitypub/process_collection_service.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+class ActivityPub::ProcessCollectionService < BaseService
+  include JsonLdHelper
+
+  def call(body, account)
+    @account = account
+    @json    = Oj.load(body, mode: :strict)
+
+    return if @account.suspended? || !supported_context?
+
+    return if different_actor? && verify_account!.nil?
+
+    case @json['type']
+    when 'Collection', 'CollectionPage'
+      process_items @json['items']
+    when 'OrderedCollection', 'OrderedCollectionPage'
+      process_items @json['orderedItems']
+    else
+      process_items [@json]
+    end
+  rescue Oj::ParseError
+    nil
+  end
+
+  private
+
+  def different_actor?
+    @json['actor'].present? && value_or_id(@json['actor']) != @account.uri && @json['signature'].present?
+  end
+
+  def process_items(items)
+    items.reverse_each.map { |item| process_item(item) }.compact
+  end
+
+  def supported_context?
+    super(@json)
+  end
+
+  def process_item(item)
+    activity = ActivityPub::Activity.factory(item, @account)
+    activity&.perform
+  end
+
+  def verify_account!
+    @account = ActivityPub::LinkedDataSignature.new(@json).verify_account!
+  end
+end
diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb
index 41815a393..b1bff8962 100644
--- a/app/services/authorize_follow_service.rb
+++ b/app/services/authorize_follow_service.rb
@@ -1,14 +1,36 @@
 # 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?
+  def call(source_account, target_account, options = {})
+    if options[:skip_follow_request]
+      follow_request = FollowRequest.new(account: source_account, target_account: target_account)
+    else
+      follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
+      follow_request.authorize!
+    end
+
+    create_notification(follow_request) unless source_account.local?
+    follow_request
   end
 
   private
 
+  def create_notification(follow_request)
+    if follow_request.account.ostatus?
+      NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id)
+    elsif follow_request.account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url)
+    end
+  end
+
+  def build_json(follow_request)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      follow_request,
+      serializer: ActivityPub::AcceptFollowSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(follow_request.target_account))
+  end
+
   def build_xml(follow_request)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.authorize_follow_request_salmon(follow_request))
   end
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index ab810c628..86eaa5735 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -15,19 +15,26 @@ class BatchedRemoveStatusService < BaseService
     @mentions = statuses.map { |s| [s.id, s.mentions.includes(:account).to_a] }.to_h
     @tags     = statuses.map { |s| [s.id, s.tags.pluck(:name)] }.to_h
 
-    @stream_entry_batches = []
-    @salmon_batches       = []
-    @json_payloads        = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id)] }.to_h
+    @stream_entry_batches  = []
+    @salmon_batches        = []
+    @activity_json_batches = []
+    @json_payloads         = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id)] }.to_h
+    @activity_json         = {}
+    @activity_xml          = {}
 
     # Ensure that rendered XML reflects destroyed state
-    Status.where(id: statuses.map(&:id)).in_batches.destroy_all
+    statuses.each(&:destroy)
 
     # Batch by source account
     statuses.group_by(&:account_id).each do |_, account_statuses|
       account = account_statuses.first.account
 
       unpush_from_home_timelines(account_statuses)
-      batch_stream_entries(account_statuses) if account.local?
+
+      if account.local?
+        batch_stream_entries(account, account_statuses)
+        batch_activity_json(account, account_statuses)
+      end
     end
 
     # Cannot be batched
@@ -36,17 +43,32 @@ class BatchedRemoveStatusService < BaseService
       batch_salmon_slaps(status) if status.local?
     end
 
-    Pubsubhubbub::DistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch }
+    Pubsubhubbub::RawDistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch }
     NotificationWorker.push_bulk(@salmon_batches) { |batch| batch }
+    ActivityPub::DeliveryWorker.push_bulk(@activity_json_batches) { |batch| batch }
   end
 
   private
 
-  def batch_stream_entries(statuses)
-    stream_entry_ids = statuses.map { |s| s.stream_entry.id }
+  def batch_stream_entries(account, statuses)
+    statuses.each do |status|
+      @stream_entry_batches << [build_xml(status.stream_entry), account.id]
+    end
+  end
 
-    stream_entry_ids.each_slice(100) do |batch_of_stream_entry_ids|
-      @stream_entry_batches << [batch_of_stream_entry_ids]
+  def batch_activity_json(account, statuses)
+    account.followers.inboxes.each do |inbox_url|
+      statuses.each do |status|
+        @activity_json_batches << [build_json(status), account.id, inbox_url]
+      end
+    end
+
+    statuses.each do |status|
+      other_recipients = (status.mentions + status.reblogs).map(&:account).reject(&:local?).select(&:activitypub?).uniq(&:id)
+
+      other_recipients.each do |target_account|
+        @activity_json_batches << [build_json(status), account.id, target_account.inbox_url]
+      end
     end
   end
 
@@ -78,11 +100,10 @@ class BatchedRemoveStatusService < BaseService
   def batch_salmon_slaps(status)
     return if @mentions[status.id].empty?
 
-    payload    = stream_entry_to_xml(status.stream_entry.reload)
-    recipients = @mentions[status.id].map(&:account).reject(&:local?).uniq(&:domain).map(&:id)
+    recipients = @mentions[status.id].map(&:account).reject(&:local?).select(&:ostatus?).uniq(&:domain).map(&:id)
 
     recipients.each do |recipient_id|
-      @salmon_batches << [payload, status.account_id, recipient_id]
+      @salmon_batches << [build_xml(status.stream_entry), status.account_id, recipient_id]
     end
   end
 
@@ -111,4 +132,24 @@ class BatchedRemoveStatusService < BaseService
   def redis
     Redis.current
   end
+
+  def build_json(status)
+    return @activity_json[status.id] if @activity_json.key?(status.id)
+
+    @activity_json[status.id] = sign_json(status, ActiveModelSerializers::SerializableResource.new(
+      status,
+      serializer: status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json)
+  end
+
+  def build_xml(stream_entry)
+    return @activity_xml[stream_entry.id] if @activity_xml.key?(stream_entry.id)
+
+    @activity_xml[stream_entry.id] = stream_entry_to_xml(stream_entry)
+  end
+
+  def sign_json(status, json)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(json).sign!(status.account))
+  end
 end
diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb
index a6b3c4cdb..1473bc841 100644
--- a/app/services/block_domain_service.rb
+++ b/app/services/block_domain_service.rb
@@ -30,7 +30,7 @@ class BlockDomainService < BaseService
 
   def suspend_accounts!
     blocked_domain_accounts.where(suspended: false).find_each do |account|
-      account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed?
+      UnsubscribeService.new.call(account) if account.subscribed?
       SuspendAccountService.new.call(account)
     end
   end
diff --git a/app/services/block_service.rb b/app/services/block_service.rb
index 5d7bf6a3b..b39c3eef2 100644
--- a/app/services/block_service.rb
+++ b/app/services/block_service.rb
@@ -12,11 +12,28 @@ class BlockService < BaseService
     block = account.block!(target_account)
 
     BlockWorker.perform_async(account.id, target_account.id)
-    NotificationWorker.perform_async(build_xml(block), account.id, target_account.id) unless target_account.local?
+    create_notification(block) unless target_account.local?
+    block
   end
 
   private
 
+  def create_notification(block)
+    if block.target_account.ostatus?
+      NotificationWorker.perform_async(build_xml(block), block.account_id, block.target_account_id)
+    elsif block.target_account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(block), block.account_id, block.target_account.inbox_url)
+    end
+  end
+
+  def build_json(block)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      block,
+      serializer: ActivityPub::BlockSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(block.account))
+  end
+
   def build_xml(block)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.block_salmon(block))
   end
diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb
index 291f9e56e..44df3ed13 100644
--- a/app/services/favourite_service.rb
+++ b/app/services/favourite_service.rb
@@ -15,18 +15,32 @@ class FavouriteService < BaseService
     return favourite unless favourite.nil?
 
     favourite = Favourite.create!(account: account, status: status)
-
-    if status.local?
-      NotifyService.new.call(favourite.status.account, favourite)
-    else
-      NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id)
-    end
-
+    create_notification(favourite)
     favourite
   end
 
   private
 
+  def create_notification(favourite)
+    status = favourite.status
+
+    if status.account.local?
+      NotifyService.new.call(status.account, favourite)
+    elsif status.account.ostatus?
+      NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id)
+    elsif status.account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url)
+    end
+  end
+
+  def build_json(favourite)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      favourite,
+      serializer: ActivityPub::LikeSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(favourite.account))
+  end
+
   def build_xml(favourite)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.favourite_salmon(favourite))
   end
diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb
index 3ac441e3e..9c5777b5d 100644
--- a/app/services/fetch_atom_service.rb
+++ b/app/services/fetch_atom_service.rb
@@ -1,21 +1,17 @@
 # frozen_string_literal: true
 
 class FetchAtomService < BaseService
+  include JsonLdHelper
+
   def call(url)
     return if url.blank?
 
-    response = Request.new(:head, url).perform
-
-    Rails.logger.debug "Remote status HEAD request returned code #{response.code}"
+    result = process(url)
 
-    response = Request.new(:get, url).perform if response.code == 405
+    # retry without ActivityPub
+    result ||= process(url) if @unsupported_activity
 
-    Rails.logger.debug "Remote status GET request returned code #{response.code}"
-
-    return nil if response.code != 200
-    return [url, fetch(url)] if response.mime_type == 'application/atom+xml'
-    return process_headers(url, response) if response['Link'].present?
-    process_html(fetch(url))
+    result
   rescue OpenSSL::SSL::SSLError => e
     Rails.logger.debug "SSL error: #{e}"
     nil
@@ -26,27 +22,67 @@ class FetchAtomService < BaseService
 
   private
 
-  def process_html(body)
-    Rails.logger.debug 'Processing HTML'
+  def process(url, terminal = false)
+    @url = url
+    perform_request
+    process_response(terminal)
+  end
+
+  def perform_request
+    accept = 'text/html'
+    accept = 'application/activity+json, application/ld+json, application/atom+xml, ' + accept unless @unsupported_activity
+
+    @response = Request.new(:get, @url)
+                       .add_headers('Accept' => accept)
+                       .perform
+  end
 
-    page = Nokogiri::HTML(body)
-    alternate_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' }
+  def process_response(terminal = false)
+    return nil if @response.code != 200
 
-    return nil if alternate_link.nil?
-    [alternate_link['href'], fetch(alternate_link['href'])]
+    if @response.mime_type == 'application/atom+xml'
+      [@url, @response.to_s, :ostatus]
+    elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@response.mime_type)
+      if supported_activity?(@response.to_s)
+        [@url, @response.to_s, :activitypub]
+      else
+        @unsupported_activity = true
+        nil
+      end
+    elsif @response['Link'] && !terminal
+      process_headers
+    elsif @response.mime_type == 'text/html' && !terminal
+      process_html
+    end
   end
 
-  def process_headers(url, response)
-    Rails.logger.debug 'Processing link header'
+  def process_html
+    page = Nokogiri::HTML(@response.to_s)
+
+    json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) }
+    atom_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' }
+
+    result ||= process(json_link['href'], terminal: true) unless json_link.nil? || @unsupported_activity
+    result ||= process(atom_link['href'], terminal: true) unless atom_link.nil?
+
+    result
+  end
+
+  def process_headers
+    link_header = LinkHeader.parse(@response['Link'].is_a?(Array) ? @response['Link'].first : @response['Link'])
+
+    json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'])
+    atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml))
 
-    link_header    = LinkHeader.parse(response['Link'].is_a?(Array) ? response['Link'].first : response['Link'])
-    alternate_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml))
+    result ||= process(json_link.href, terminal: true) unless json_link.nil? || @unsupported_activity
+    result ||= process(atom_link.href, terminal: true) unless atom_link.nil?
 
-    return process_html(fetch(url)) if alternate_link.nil?
-    [alternate_link.href, fetch(alternate_link.href)]
+    result
   end
 
-  def fetch(url)
-    Request.new(:get, url).perform.to_s
+  def supported_activity?(body)
+    json = body_to_json(body)
+    return false unless supported_context?(json)
+    json['type'] == 'Person' ? json['inbox'].present? : true
   end
 end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 20c85e0ea..c38e9e7df 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -4,29 +4,45 @@ class FetchLinkCardService < BaseService
   URL_PATTERN = %r{https?://\S+}
 
   def call(status)
-    # Get first http/https URL that isn't local
-    url = parse_urls(status)
+    @status = status
+    @url    = parse_urls
 
-    return if url.nil?
+    return if @url.nil? || @status.preview_cards.any?
 
-    url  = url.to_s
-    card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url)
-    res  = Request.new(:head, url).perform
+    @url = @url.to_s
 
-    return if res.code != 200 || res.mime_type != 'text/html'
+    RedisLock.acquire(lock_options) do |lock|
+      if lock.acquired?
+        @card = PreviewCard.find_by(url: @url)
+        process_url if @card.nil?
+      end
+    end
 
-    attempt_opengraph(card, url) unless attempt_oembed(card, url)
+    attach_card unless @card.nil?
   rescue HTTP::ConnectionError, OpenSSL::SSL::SSLError
     nil
   end
 
   private
 
-  def parse_urls(status)
-    if status.local?
-      urls = status.text.match(URL_PATTERN).to_a.map { |uri| Addressable::URI.parse(uri).normalize }
+  def process_url
+    @card = PreviewCard.new(url: @url)
+    res   = Request.new(:head, @url).perform
+
+    return if res.code != 200 || res.mime_type != 'text/html'
+
+    attempt_oembed || attempt_opengraph
+  end
+
+  def attach_card
+    @status.preview_cards << @card
+  end
+
+  def parse_urls
+    if @status.local?
+      urls = @status.text.match(URL_PATTERN).to_a.map { |uri| Addressable::URI.parse(uri).normalize }
     else
-      html  = Nokogiri::HTML(status.text)
+      html  = Nokogiri::HTML(@status.text)
       links = html.css('a')
       urls  = links.map { |a| Addressable::URI.parse(a['href']).normalize unless skip_link?(a) }.compact
     end
@@ -44,41 +60,41 @@ class FetchLinkCardService < BaseService
     a['rel']&.include?('tag') || a['class']&.include?('u-url')
   end
 
-  def attempt_oembed(card, url)
-    response = OEmbed::Providers.get(url)
+  def attempt_oembed
+    response = OEmbed::Providers.get(@url)
 
-    card.type          = response.type
-    card.title         = response.respond_to?(:title)         ? response.title         : ''
-    card.author_name   = response.respond_to?(:author_name)   ? response.author_name   : ''
-    card.author_url    = response.respond_to?(:author_url)    ? response.author_url    : ''
-    card.provider_name = response.respond_to?(:provider_name) ? response.provider_name : ''
-    card.provider_url  = response.respond_to?(:provider_url)  ? response.provider_url  : ''
-    card.width         = 0
-    card.height        = 0
+    @card.type          = response.type
+    @card.title         = response.respond_to?(:title)         ? response.title         : ''
+    @card.author_name   = response.respond_to?(:author_name)   ? response.author_name   : ''
+    @card.author_url    = response.respond_to?(:author_url)    ? response.author_url    : ''
+    @card.provider_name = response.respond_to?(:provider_name) ? response.provider_name : ''
+    @card.provider_url  = response.respond_to?(:provider_url)  ? response.provider_url  : ''
+    @card.width         = 0
+    @card.height        = 0
 
-    case card.type
+    case @card.type
     when 'link'
-      card.image = URI.parse(response.thumbnail_url) if response.respond_to?(:thumbnail_url)
+      @card.image = URI.parse(response.thumbnail_url) if response.respond_to?(:thumbnail_url)
     when 'photo'
-      card.url    = response.url
-      card.width  = response.width.presence  || 0
-      card.height = response.height.presence || 0
+      @card.url    = response.url
+      @card.width  = response.width.presence  || 0
+      @card.height = response.height.presence || 0
     when 'video'
-      card.width  = response.width.presence  || 0
-      card.height = response.height.presence || 0
-      card.html   = Formatter.instance.sanitize(response.html, Sanitize::Config::MASTODON_OEMBED)
+      @card.width  = response.width.presence  || 0
+      @card.height = response.height.presence || 0
+      @card.html   = Formatter.instance.sanitize(response.html, Sanitize::Config::MASTODON_OEMBED)
     when 'rich'
       # Most providers rely on <script> tags, which is a no-no
       return false
     end
 
-    card.save_with_optional_image!
+    @card.save_with_optional_image!
   rescue OEmbed::NotFound
     false
   end
 
-  def attempt_opengraph(card, url)
-    response = Request.new(:get, url).perform
+  def attempt_opengraph
+    response = Request.new(:get, @url).perform
 
     return if response.code != 200 || response.mime_type != 'text/html'
 
@@ -88,19 +104,23 @@ class FetchLinkCardService < BaseService
     detector.strip_tags = true
 
     guess = detector.detect(html, response.charset)
-    page = Nokogiri::HTML(html, nil, guess&.fetch(:encoding))
+    page  = Nokogiri::HTML(html, nil, guess&.fetch(:encoding))
 
-    card.type             = :link
-    card.title            = meta_property(page, 'og:title') || page.at_xpath('//title')&.content
-    card.description      = meta_property(page, 'og:description') || meta_property(page, 'description')
-    card.image_remote_url = meta_property(page, 'og:image') if meta_property(page, 'og:image')
+    @card.type             = :link
+    @card.title            = meta_property(page, 'og:title') || page.at_xpath('//title')&.content || ''
+    @card.description      = meta_property(page, 'og:description') || meta_property(page, 'description') || ''
+    @card.image_remote_url = meta_property(page, 'og:image') if meta_property(page, 'og:image')
 
-    return if card.title.blank?
+    return if @card.title.blank?
 
-    card.save_with_optional_image!
+    @card.save_with_optional_image!
   end
 
   def meta_property(html, property)
     html.at_xpath("//meta[@property=\"#{property}\"]")&.attribute('content')&.value || html.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value
   end
+
+  def lock_options
+    { redis: Redis.current, key: "fetch:#{@url}" }
+  end
 end
diff --git a/app/services/fetch_remote_account_service.rb b/app/services/fetch_remote_account_service.rb
index 8eed0d454..7c618a0b0 100644
--- a/app/services/fetch_remote_account_service.rb
+++ b/app/services/fetch_remote_account_service.rb
@@ -3,16 +3,20 @@
 class FetchRemoteAccountService < BaseService
   include AuthorExtractor
 
-  def call(url, prefetched_body = nil)
+  def call(url, prefetched_body = nil, protocol = :ostatus)
     if prefetched_body.nil?
-      atom_url, body = FetchAtomService.new.call(url)
+      resource_url, body, protocol = FetchAtomService.new.call(url)
     else
-      atom_url = url
-      body     = prefetched_body
+      resource_url = url
+      body         = prefetched_body
     end
 
-    return nil if atom_url.nil?
-    process_atom(atom_url, body)
+    case protocol
+    when :ostatus
+      process_atom(resource_url, body)
+    when :activitypub
+      ActivityPub::FetchRemoteAccountService.new.call(resource_url, body)
+    end
   end
 
   private
diff --git a/app/services/fetch_remote_resource_service.rb b/app/services/fetch_remote_resource_service.rb
index 2c1c1f05f..341664272 100644
--- a/app/services/fetch_remote_resource_service.rb
+++ b/app/services/fetch_remote_resource_service.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class FetchRemoteResourceService < BaseService
+  include JsonLdHelper
+
   attr_reader :url
 
   def call(url)
@@ -14,11 +16,11 @@ class FetchRemoteResourceService < BaseService
   private
 
   def process_url
-    case xml_root
-    when 'feed'
-      FetchRemoteAccountService.new.call(atom_url, body)
-    when 'entry'
-      FetchRemoteStatusService.new.call(atom_url, body)
+    case type
+    when 'Person'
+      FetchRemoteAccountService.new.call(atom_url, body, protocol)
+    when 'Note'
+      FetchRemoteStatusService.new.call(atom_url, body, protocol)
     end
   end
 
@@ -31,7 +33,26 @@ class FetchRemoteResourceService < BaseService
   end
 
   def body
-    fetched_atom_feed.last
+    fetched_atom_feed.second
+  end
+
+  def protocol
+    fetched_atom_feed.third
+  end
+
+  def type
+    return json_data['type'] if protocol == :activitypub
+
+    case xml_root
+    when 'feed'
+      'Person'
+    when 'entry'
+      'Note'
+    end
+  end
+
+  def json_data
+    @_json_data ||= body_to_json(body)
   end
 
   def xml_root
diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb
index b9f5f97b1..18af18059 100644
--- a/app/services/fetch_remote_status_service.rb
+++ b/app/services/fetch_remote_status_service.rb
@@ -3,16 +3,20 @@
 class FetchRemoteStatusService < BaseService
   include AuthorExtractor
 
-  def call(url, prefetched_body = nil)
+  def call(url, prefetched_body = nil, protocol = :ostatus)
     if prefetched_body.nil?
-      atom_url, body = FetchAtomService.new.call(url)
+      resource_url, body, protocol = FetchAtomService.new.call(url)
     else
-      atom_url = url
-      body     = prefetched_body
+      resource_url = url
+      body         = prefetched_body
     end
 
-    return nil if atom_url.nil?
-    process_atom(atom_url, body)
+    case protocol
+    when :ostatus
+      process_atom(resource_url, body)
+    when :activitypub
+      ActivityPub::FetchRemoteStatusService.new.call(resource_url, body)
+    end
   end
 
   private
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index 3155feaa4..a92eb6b88 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -14,7 +14,7 @@ class FollowService < BaseService
 
     return if source_account.following?(target_account)
 
-    if target_account.locked?
+    if target_account.locked? || target_account.activitypub?
       request_follow(source_account, target_account)
     else
       direct_follow(source_account, target_account)
@@ -28,9 +28,11 @@ class FollowService < BaseService
 
     if target_account.local?
       NotifyService.new.call(target_account, follow_request)
-    else
+    elsif target_account.ostatus?
       NotificationWorker.perform_async(build_follow_request_xml(follow_request), source_account.id, target_account.id)
       AfterRemoteFollowRequestWorker.perform_async(follow_request.id)
+    elsif target_account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), source_account.id, target_account.inbox_url)
     end
 
     follow_request
@@ -63,4 +65,12 @@ class FollowService < BaseService
   def build_follow_xml(follow)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.follow_salmon(follow))
   end
+
+  def build_json(follow_request)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      follow_request,
+      serializer: ActivityPub::FollowSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(follow_request.account))
+  end
 end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 0ecd8a9cd..56011a005 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -42,6 +42,8 @@ class PostStatusService < BaseService
     # match both with and without U+FE0F (the emoji variation selector)
     unless /👁\ufe0f?\z/.match?(status.content)
       Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
+      ActivityPub::DistributionWorker.perform_async(status.id)
+      ActivityPub::ReplyDistributionWorker.perform_async(status.id) if status.reply? && status.thread.account.local?
     end
 
     if options[:idempotency].present?
diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb
index cc99cde03..d04e926e7 100644
--- a/app/services/process_interaction_service.rb
+++ b/app/services/process_interaction_service.rb
@@ -67,10 +67,13 @@ class ProcessInteractionService < BaseService
 
   def follow!(account, target_account)
     follow = account.follow!(target_account)
+    FollowRequest.find_by(account: account, target_account: target_account)&.destroy
     NotifyService.new.call(target_account, follow)
   end
 
   def follow_request!(account, target_account)
+    return if account.requested?(target_account)
+
     follow_request = FollowRequest.create!(account: account, target_account: target_account)
     NotifyService.new.call(target_account, follow_request)
   end
@@ -88,6 +91,7 @@ class ProcessInteractionService < BaseService
 
   def unfollow!(account, target_account)
     account.unfollow!(target_account)
+    FollowRequest.find_by(account: account, target_account: target_account)&.destroy
   end
 
   def reflect_block!(account, target_account)
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index 438033d22..f123bf869 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -28,18 +28,32 @@ class ProcessMentionsService < BaseService
     end
 
     status.mentions.includes(:account).each do |mention|
-      mentioned_account = mention.account
-
-      if mentioned_account.local?
-        NotifyService.new.call(mentioned_account, mention)
-      else
-        NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id)
-      end
+      create_notification(status, mention)
     end
   end
 
   private
 
+  def create_notification(status, mention)
+    mentioned_account = mention.account
+
+    if mentioned_account.local?
+      NotifyService.new.call(mentioned_account, mention)
+    elsif mentioned_account.ostatus? && (Rails.configuration.x.use_ostatus_privacy || !status.stream_entry.hidden?)
+      NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id)
+    elsif mentioned_account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(mention.status), mention.status.account_id, mentioned_account.inbox_url)
+    end
+  end
+
+  def build_json(status)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      status,
+      serializer: ActivityPub::ActivitySerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(status.account))
+  end
+
   def follow_remote_account_service
     @follow_remote_account_service ||= ResolveRemoteAccountService.new
   end
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 497cdb4f5..52e3ba0e0 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -20,17 +20,35 @@ class ReblogService < BaseService
     reblog = account.statuses.create!(reblog: reblogged_status, text: '')
 
     DistributionWorker.perform_async(reblog.id)
+
     unless /👁$/.match?(reblogged_status.content)
       Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id)
+      ActivityPub::DistributionWorker.perform_async(reblog.id)
     end
 
+    create_notification(reblog)
+    reblog
+  end
+
+  private
 
-    if reblogged_status.local?
-      NotifyService.new.call(reblog.reblog.account, reblog)
-    else
-      NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), account.id, reblog.reblog.account_id)
+  def create_notification(reblog)
+    reblogged_status = reblog.reblog
+
+    if reblogged_status.account.local?
+      NotifyService.new.call(reblogged_status.account, reblog)
+    elsif reblogged_status.account.ostatus?
+      NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), reblog.account_id, reblogged_status.account_id)
+    elsif reblogged_status.account.activitypub? && !reblogged_status.account.following?(reblog.account)
+      ActivityPub::DeliveryWorker.perform_async(build_json(reblog), reblog.account_id, reblogged_status.account.inbox_url)
     end
+  end
 
-    reblog
+  def build_json(reblog)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      reblog,
+      serializer: ActivityPub::ActivitySerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(reblog.account))
   end
 end
diff --git a/app/services/reject_follow_service.rb b/app/services/reject_follow_service.rb
index fd7e66c23..c1f7bcb60 100644
--- a/app/services/reject_follow_service.rb
+++ b/app/services/reject_follow_service.rb
@@ -4,11 +4,28 @@ 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?
+    create_notification(follow_request) unless source_account.local?
+    follow_request
   end
 
   private
 
+  def create_notification(follow_request)
+    if follow_request.account.ostatus?
+      NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id)
+    elsif follow_request.account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url)
+    end
+  end
+
+  def build_json(follow_request)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      follow_request,
+      serializer: ActivityPub::RejectFollowSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(follow_request.target_account))
+  end
+
   def build_xml(follow_request)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.reject_follow_request_salmon(follow_request))
   end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index a5281f586..83fc77043 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -22,8 +22,8 @@ class RemoveStatusService < BaseService
 
     return unless @account.local?
 
-    remove_from_mentioned(@stream_entry.reload)
-    Pubsubhubbub::DistributionWorker.perform_async(@stream_entry.id)
+    remove_from_remote_followers
+    remove_from_remote_affected
   end
 
   private
@@ -38,15 +38,52 @@ class RemoveStatusService < BaseService
     end
   end
 
-  def remove_from_mentioned(stream_entry)
-    salmon_xml       = stream_entry_to_xml(stream_entry)
-    target_accounts  = @mentions.map(&:account).reject(&:local?).uniq(&:domain)
+  def remove_from_remote_affected
+    # People who got mentioned in the status, or who
+    # reblogged it from someone else might not follow
+    # the author and wouldn't normally receive the
+    # delete notification - so here, we explicitly
+    # send it to them
 
-    NotificationWorker.push_bulk(target_accounts) do |target_account|
-      [salmon_xml, stream_entry.account_id, target_account.id]
+    target_accounts = (@mentions.map(&:account).reject(&:local?) + @reblogs.map(&:account).reject(&:local?)).uniq(&:id)
+
+    # Ostatus
+    NotificationWorker.push_bulk(target_accounts.select(&:ostatus?).uniq(&:domain)) do |target_account|
+      [salmon_xml, @account.id, target_account.id]
+    end
+
+    # ActivityPub
+    ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:inbox_url)) do |target_account|
+      [signed_activity_json, @account.id, target_account.inbox_url]
+    end
+  end
+
+  def remove_from_remote_followers
+    # OStatus
+    Pubsubhubbub::RawDistributionWorker.perform_async(salmon_xml, @account.id)
+
+    # ActivityPub
+    ActivityPub::DeliveryWorker.push_bulk(@account.followers.inboxes) do |inbox_url|
+      [signed_activity_json, @account.id, inbox_url]
     end
   end
 
+  def salmon_xml
+    @salmon_xml ||= stream_entry_to_xml(@stream_entry)
+  end
+
+  def signed_activity_json
+    @signed_activity_json ||= Oj.dump(ActivityPub::LinkedDataSignature.new(activity_json).sign!(@account))
+  end
+
+  def activity_json
+    @activity_json ||= ActiveModelSerializers::SerializableResource.new(
+      @status,
+      serializer: @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json
+  end
+
   def remove_reblogs
     # We delete reblogs of the status before the original status,
     # because once original status is gone, reblogs will disappear
diff --git a/app/services/resolve_remote_account_service.rb b/app/services/resolve_remote_account_service.rb
index e0e2ebc83..7031c98f5 100644
--- a/app/services/resolve_remote_account_service.rb
+++ b/app/services/resolve_remote_account_service.rb
@@ -2,6 +2,7 @@
 
 class ResolveRemoteAccountService < BaseService
   include OStatus2::MagicKey
+  include JsonLdHelper
 
   DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0'
 
@@ -12,6 +13,7 @@ class ResolveRemoteAccountService < BaseService
   # @return [Account]
   def call(uri, update_profile = true, redirected = nil)
     @username, @domain = uri.split('@')
+    @update_profile    = update_profile
 
     return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
 
@@ -42,10 +44,11 @@ class ResolveRemoteAccountService < BaseService
       if lock.acquired?
         @account = Account.find_remote(@username, @domain)
 
-        create_account if @account.nil?
-        update_account
-
-        update_account_profile if update_profile
+        if activitypub_ready?
+          handle_activitypub
+        else
+          handle_ostatus
+        end
       end
     end
 
@@ -58,18 +61,46 @@ class ResolveRemoteAccountService < BaseService
   private
 
   def links_missing?
-    @webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? ||
+    !(activitypub_ready? || ostatus_ready?)
+  end
+
+  def ostatus_ready?
+    !(@webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? ||
       @webfinger.link('salmon').nil? ||
       @webfinger.link('http://webfinger.net/rel/profile-page').nil? ||
       @webfinger.link('magic-public-key').nil? ||
       canonical_uri.nil? ||
-      hub_url.nil?
+      hub_url.nil?)
   end
 
   def webfinger_update_due?
     @account.nil? || @account.last_webfingered_at.nil? || @account.last_webfingered_at <= 1.day.ago
   end
 
+  def activitypub_ready?
+    !@webfinger.link('self').nil? &&
+      ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type) &&
+      actor_json['inbox'].present?
+  end
+
+  def handle_ostatus
+    create_account if @account.nil?
+    update_account
+    update_account_profile if update_profile?
+  end
+
+  def update_profile?
+    @update_profile
+  end
+
+  def handle_activitypub
+    return if actor_json.nil?
+
+    @account = ActivityPub::ProcessAccountService.new.call(@username, @domain, actor_json)
+  rescue Oj::ParseError
+    nil
+  end
+
   def create_account
     Rails.logger.debug "Creating new remote account for #{@username}@#{@domain}"
 
@@ -81,6 +112,7 @@ class ResolveRemoteAccountService < BaseService
 
   def update_account
     @account.last_webfingered_at = Time.now.utc
+    @account.protocol            = :ostatus
     @account.remote_url          = atom_url
     @account.salmon_url          = salmon_url
     @account.url                 = url
@@ -111,6 +143,10 @@ class ResolveRemoteAccountService < BaseService
     @salmon_url ||= @webfinger.link('salmon').href
   end
 
+  def actor_url
+    @actor_url ||= @webfinger.link('self').href
+  end
+
   def url
     @url ||= @webfinger.link('http://webfinger.net/rel/profile-page').href
   end
@@ -149,6 +185,13 @@ class ResolveRemoteAccountService < BaseService
     @atom_body = response.to_s
   end
 
+  def actor_json
+    return @actor_json if defined?(@actor_json)
+
+    json        = fetch_resource(actor_url)
+    @actor_json = supported_context?(json) && json['type'] == 'Person' ? json : nil
+  end
+
   def atom
     return @atom if defined?(@atom)
     @atom = Nokogiri::XML(atom_body)
diff --git a/app/services/subscribe_service.rb b/app/services/subscribe_service.rb
index d3e41e691..bfa7ff8c8 100644
--- a/app/services/subscribe_service.rb
+++ b/app/services/subscribe_service.rb
@@ -2,7 +2,7 @@
 
 class SubscribeService < BaseService
   def call(account)
-    return unless account.ostatus?
+    return if account.hub_url.blank?
 
     @account        = account
     @account.secret = SecureRandom.hex
@@ -42,7 +42,7 @@ class SubscribeService < BaseService
   end
 
   def some_local_account
-    @some_local_account ||= Account.local.first
+    @some_local_account ||= Account.local.where(suspended: false).first
   end
 
   # Any response in the 3xx or 4xx range, except for 429 (rate limit)
diff --git a/app/services/unblock_service.rb b/app/services/unblock_service.rb
index ff15c7275..869f62d1c 100644
--- a/app/services/unblock_service.rb
+++ b/app/services/unblock_service.rb
@@ -5,11 +5,28 @@ class UnblockService < BaseService
     return unless account.blocking?(target_account)
 
     unblock = account.unblock!(target_account)
-    NotificationWorker.perform_async(build_xml(unblock), account.id, target_account.id) unless target_account.local?
+    create_notification(unblock) unless target_account.local?
+    unblock
   end
 
   private
 
+  def create_notification(unblock)
+    if unblock.target_account.ostatus?
+      NotificationWorker.perform_async(build_xml(unblock), unblock.account_id, unblock.target_account_id)
+    elsif unblock.target_account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(unblock), unblock.account_id, unblock.target_account.inbox_url)
+    end
+  end
+
+  def build_json(unblock)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      unblock,
+      serializer: ActivityPub::UndoBlockSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(unblock.account))
+  end
+
   def build_xml(block)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unblock_salmon(block))
   end
diff --git a/app/services/unfavourite_service.rb b/app/services/unfavourite_service.rb
index 564aaee46..2fda11bd6 100644
--- a/app/services/unfavourite_service.rb
+++ b/app/services/unfavourite_service.rb
@@ -4,14 +4,30 @@ class UnfavouriteService < BaseService
   def call(account, status)
     favourite = Favourite.find_by!(account: account, status: status)
     favourite.destroy!
-
-    NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id) unless status.local?
-
+    create_notification(favourite) unless status.local?
     favourite
   end
 
   private
 
+  def create_notification(favourite)
+    status = favourite.status
+
+    if status.account.ostatus?
+      NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id)
+    elsif status.account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url)
+    end
+  end
+
+  def build_json(favourite)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      favourite,
+      serializer: ActivityPub::UndoLikeSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(favourite.account))
+  end
+
   def build_xml(favourite)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfavourite_salmon(favourite))
   end
diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb
index 388909586..73a64929f 100644
--- a/app/services/unfollow_service.rb
+++ b/app/services/unfollow_service.rb
@@ -5,14 +5,51 @@ class UnfollowService < BaseService
   # @param [Account] source_account Where to unfollow from
   # @param [Account] target_account Which to unfollow
   def call(source_account, target_account)
-    follow = source_account.unfollow!(target_account)
-    return unless follow
-    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)
+    @source_account = source_account
+    @target_account = target_account
+
+    unfollow! || undo_follow_request!
   end
 
   private
 
+  def unfollow!
+    follow = Follow.find_by(account: @source_account, target_account: @target_account)
+
+    return unless follow
+
+    follow.destroy!
+    create_notification(follow) unless @target_account.local?
+    UnmergeWorker.perform_async(@target_account.id, @source_account.id)
+    follow
+  end
+
+  def undo_follow_request!
+    follow_request = FollowRequest.find_by(account: @source_account, target_account: @target_account)
+
+    return unless follow_request
+
+    follow_request.destroy!
+    create_notification(follow_request) unless @target_account.local?
+    follow_request
+  end
+
+  def create_notification(follow)
+    if follow.target_account.ostatus?
+      NotificationWorker.perform_async(build_xml(follow), follow.account_id, follow.target_account_id)
+    elsif follow.target_account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(follow), follow.account_id, follow.target_account.inbox_url)
+    end
+  end
+
+  def build_json(follow)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      follow,
+      serializer: ActivityPub::UndoFollowSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(follow.account))
+  end
+
   def build_xml(follow)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfollow_salmon(follow))
   end
diff --git a/app/services/unsubscribe_service.rb b/app/services/unsubscribe_service.rb
index c5e0e73fe..865f783bc 100644
--- a/app/services/unsubscribe_service.rb
+++ b/app/services/unsubscribe_service.rb
@@ -2,7 +2,7 @@
 
 class UnsubscribeService < BaseService
   def call(account)
-    return unless account.ostatus?
+    return if account.hub_url.blank?
 
     @account  = account
     @response = build_request.perform
diff --git a/app/services/update_account_service.rb b/app/services/update_account_service.rb
new file mode 100644
index 000000000..09ea377e7
--- /dev/null
+++ b/app/services/update_account_service.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class UpdateAccountService < BaseService
+  def call(account, params, raise_error: false)
+    was_locked = account.locked
+    update_method = raise_error ? :update! : :update
+    account.send(update_method, params).tap do |ret|
+      next unless ret
+      authorize_all_follow_requests(account) if was_locked && !account.locked
+    end
+  end
+
+  private
+
+  def authorize_all_follow_requests(account)
+    follow_requests = FollowRequest.where(target_account: account)
+    AuthorizeFollowWorker.push_bulk(follow_requests) do |req|
+      [req.account_id, req.target_account_id]
+    end
+  end
+end