Browse Source

Add ActivityPub inbox (#4216)

* Add ActivityPub inbox

* Handle ActivityPub deletes

* Handle ActivityPub creates

* Handle ActivityPub announces

* Stubs for handling all activities that need to be handled

* Add ActivityPub actor resolving

* Handle conversation URI passing in ActivityPub

* Handle content language in ActivityPub

* Send accept header when fetching actor, handle JSON parse errors

* Test for ActivityPub::FetchRemoteAccountService

* Handle public key and icon/image when embedded/as array/as resolvable URI

* Implement ActivityPub::FetchRemoteStatusService

* Add stubs for more interactions

* Undo activities implemented

* Handle out of order activities

* Hook up ActivityPub to ResolveRemoteAccountService, handle
Update Account activities

* Add fragment IDs to all transient activity serializers

* Add tests and fixes

* Add stubs for missing tests

* Add more tests

* Add more tests
Eugen Rochko 6 years ago
parent
commit
dd7ef0dc41
50 changed files with 1652 additions and 21 deletions
  1. 30 0
      app/controllers/activitypub/inboxes_controller.rb
  2. 31 0
      app/helpers/jsonld_helper.rb
  3. 109 0
      app/lib/activitypub/activity.rb
  4. 14 0
      app/lib/activitypub/activity/announce.rb
  5. 12 0
      app/lib/activitypub/activity/block.rb
  6. 148 0
      app/lib/activitypub/activity/create.rb
  7. 13 0
      app/lib/activitypub/activity/delete.rb
  8. 12 0
      app/lib/activitypub/activity/follow.rb
  9. 12 0
      app/lib/activitypub/activity/like.rb
  10. 69 0
      app/lib/activitypub/activity/undo.rb
  11. 17 0
      app/lib/activitypub/activity/update.rb
  12. 1 1
      app/lib/activitypub/adapter.rb
  13. 25 0
      app/lib/activitypub/tag_manager.rb
  14. 2 0
      app/models/concerns/remotable.rb
  15. 5 1
      app/serializers/activitypub/accept_follow_serializer.rb
  16. 33 4
      app/serializers/activitypub/actor_serializer.rb
  17. 5 1
      app/serializers/activitypub/block_serializer.rb
  18. 5 1
      app/serializers/activitypub/delete_serializer.rb
  19. 5 1
      app/serializers/activitypub/follow_serializer.rb
  20. 5 1
      app/serializers/activitypub/like_serializer.rb
  21. 5 1
      app/serializers/activitypub/reject_follow_serializer.rb
  22. 5 1
      app/serializers/activitypub/undo_block_serializer.rb
  23. 5 1
      app/serializers/activitypub/undo_follow_serializer.rb
  24. 5 1
      app/serializers/activitypub/undo_like_serializer.rb
  25. 5 1
      app/serializers/activitypub/update_serializer.rb
  26. 57 0
      app/services/activitypub/fetch_remote_account_service.rb
  27. 36 0
      app/services/activitypub/fetch_remote_status_service.rb
  28. 84 0
      app/services/activitypub/process_account_service.rb
  29. 38 0
      app/services/activitypub/process_collection_service.rb
  30. 43 6
      app/services/resolve_remote_account_service.rb
  31. 11 0
      app/workers/activitypub/processing_worker.rb
  32. 17 0
      app/workers/activitypub/thread_resolve_worker.rb
  33. 1 0
      config/initializers/inflections.rb
  34. 1 0
      config/routes.rb
  35. 7 0
      spec/controllers/activitypub/inboxes_controller_spec.rb
  36. 19 0
      spec/controllers/activitypub/outboxes_controller_spec.rb
  37. 35 0
      spec/helpers/jsonld_helper_spec.rb
  38. 29 0
      spec/lib/activitypub/activity/announce_spec.rb
  39. 28 0
      spec/lib/activitypub/activity/block_spec.rb
  40. 221 0
      spec/lib/activitypub/activity/create_spec.rb
  41. 28 0
      spec/lib/activitypub/activity/delete_spec.rb
  42. 28 0
      spec/lib/activitypub/activity/follow_spec.rb
  43. 29 0
      spec/lib/activitypub/activity/like_spec.rb
  44. 107 0
      spec/lib/activitypub/activity/undo_spec.rb
  45. 41 0
      spec/lib/activitypub/activity/update_spec.rb
  46. 99 0
      spec/lib/activitypub/tag_manager_spec.rb
  47. 96 0
      spec/services/activitypub/fetch_remote_account_service_spec.rb
  48. 5 0
      spec/services/activitypub/fetch_remote_status_service_spec.rb
  49. 5 0
      spec/services/activitypub/process_account_service_spec.rb
  50. 9 0
      spec/services/activitypub/process_collection_service_spec.rb

+ 30 - 0
app/controllers/activitypub/inboxes_controller.rb

@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class ActivityPub::InboxesController < Api::BaseController
+  include SignatureVerification
+
+  before_action :set_account
+
+  def create
+    if signed_request_account
+      process_payload
+      head 201
+    else
+      head 202
+    end
+  end
+
+  private
+
+  def set_account
+    @account = Account.find_local!(params[:account_username])
+  end
+
+  def body
+    @body ||= request.body.read
+  end
+
+  def process_payload
+    ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body.force_encoding('UTF-8'))
+  end
+end

+ 31 - 0
app/helpers/jsonld_helper.rb

@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module JsonLdHelper
+  def equals_or_includes?(haystack, needle)
+    haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle
+  end
+
+  def first_of_value(value)
+    value.is_a?(Array) ? value.first : value
+  end
+
+  def supported_context?(json)
+    equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT)
+  end
+
+  def fetch_resource(uri)
+    response = build_request(uri).perform
+    return if response.code != 200
+    Oj.load(response.to_s, mode: :strict)
+  rescue Oj::ParseError
+    nil
+  end
+
+  private
+
+  def build_request(uri)
+    request = Request.new(:get, uri)
+    request.add_headers('Accept' => 'application/activity+json')
+    request
+  end
+end

+ 109 - 0
app/lib/activitypub/activity.rb

@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity
+  include JsonLdHelper
+
+  def initialize(json, account)
+    @json    = json
+    @account = account
+    @object  = @json['object']
+  end
+
+  def perform
+    raise NotImplementedError
+  end
+
+  class << self
+    def factory(json, account)
+      @json = json
+      klass&.new(json, account)
+    end
+
+    private
+
+    def klass
+      case @json['type']
+      when 'Create'
+        ActivityPub::Activity::Create
+      when 'Announce'
+        ActivityPub::Activity::Announce
+      when 'Delete'
+        ActivityPub::Activity::Delete
+      when 'Follow'
+        ActivityPub::Activity::Follow
+      when 'Like'
+        ActivityPub::Activity::Like
+      when 'Block'
+        ActivityPub::Activity::Block
+      when 'Update'
+        ActivityPub::Activity::Update
+      when 'Undo'
+        ActivityPub::Activity::Undo
+      end
+    end
+  end
+
+  protected
+
+  def status_from_uri(uri)
+    ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
+  end
+
+  def account_from_uri(uri)
+    ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
+  end
+
+  def object_uri
+    @object_uri ||= @object.is_a?(String) ? @object : @object['id']
+  end
+
+  def redis
+    Redis.current
+  end
+
+  def distribute(status)
+    notify_about_reblog(status) if reblog_of_local_account?(status)
+    notify_about_mentions(status)
+    crawl_links(status)
+    distribute_to_followers(status)
+  end
+
+  def reblog_of_local_account?(status)
+    status.reblog? && status.reblog.account.local?
+  end
+
+  def notify_about_reblog(status)
+    NotifyService.new.call(status.reblog.account, status)
+  end
+
+  def notify_about_mentions(status)
+    status.mentions.includes(:account).each do |mention|
+      next unless mention.account.local? && audience_includes?(mention.account)
+      NotifyService.new.call(mention.account, mention)
+    end
+  end
+
+  def crawl_links(status)
+    return if status.spoiler_text?
+    LinkCrawlWorker.perform_async(status.id)
+  end
+
+  def distribute_to_followers(status)
+    DistributionWorker.perform_async(status.id)
+  end
+
+  def delete_arrived_first?(uri)
+    key = "delete_upon_arrival:#{@account.id}:#{uri}"
+
+    if redis.exists(key)
+      redis.del(key)
+      true
+    else
+      false
+    end
+  end
+
+  def delete_later!(uri)
+    redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri)
+  end
+end

+ 14 - 0
app/lib/activitypub/activity/announce.rb

@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Announce < ActivityPub::Activity
+  def perform
+    original_status = status_from_uri(object_uri)
+    original_status = ActivityPub::FetchRemoteStatusService.new.call(object_uri) if original_status.nil?
+
+    return if original_status.nil? || delete_arrived_first?(@json['id'])
+
+    status = Status.create!(account: @account, reblog: original_status, uri: @json['id'])
+    distribute(status)
+    status
+  end
+end

+ 12 - 0
app/lib/activitypub/activity/block.rb

@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Block < ActivityPub::Activity
+  def perform
+    target_account = account_from_uri(object_uri)
+
+    return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id'])
+
+    UnfollowService.new.call(target_account, @account) if target_account.following?(@account)
+    @account.block!(target_account)
+  end
+end

+ 148 - 0
app/lib/activitypub/activity/create.rb

@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Create < ActivityPub::Activity
+  def perform
+    return if delete_arrived_first?(object_uri) || unsupported_object_type?
+
+    status = Status.find_by(uri: object_uri)
+
+    return status unless status.nil?
+
+    ApplicationRecord.transaction do
+      status = Status.create!(status_params)
+
+      process_tags(status)
+      process_attachments(status)
+    end
+
+    resolve_thread(status)
+    distribute(status)
+
+    status
+  end
+
+  private
+
+  def status_params
+    {
+      uri: @object['id'],
+      url: @object['url'],
+      account: @account,
+      text: text_from_content || '',
+      language: language_from_content,
+      spoiler_text: @object['summary'] || '',
+      created_at: @object['published'] || Time.now.utc,
+      reply: @object['inReplyTo'].present?,
+      sensitive: @object['sensitive'] || false,
+      visibility: visibility_from_audience,
+      thread: replied_to_status,
+      conversation: conversation_from_uri(@object['_:conversation']),
+    }
+  end
+
+  def process_tags(status)
+    return unless @object['tag'].is_a?(Array)
+
+    @object['tag'].each do |tag|
+      case tag['type']
+      when 'Hashtag'
+        process_hashtag tag, status
+      when 'Mention'
+        process_mention tag, status
+      end
+    end
+  end
+
+  def process_hashtag(tag, status)
+    hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
+    hashtag = Tag.where(name: hashtag).first_or_initialize(name: hashtag)
+
+    status.tags << hashtag
+  end
+
+  def process_mention(tag, status)
+    account = account_from_uri(tag['href'])
+    account = ActivityPub::FetchRemoteAccountService.new.call(tag['href']) if account.nil?
+    return if account.nil?
+    account.mentions.create(status: status)
+  end
+
+  def process_attachments(status)
+    return unless @object['attachment'].is_a?(Array)
+
+    @object['attachment'].each do |attachment|
+      next if unsupported_media_type?(attachment['mediaType'])
+
+      href             = Addressable::URI.parse(attachment['url']).normalize.to_s
+      media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href)
+
+      next if skip_download?
+
+      media_attachment.file_remote_url = href
+      media_attachment.save
+    end
+  end
+
+  def resolve_thread(status)
+    return unless status.reply? && status.thread.nil?
+    ActivityPub::ThreadResolveWorker.perform_async(status.id, @object['inReplyTo'])
+  end
+
+  def conversation_from_uri(uri)
+    return nil if uri.nil?
+    return Conversation.find_by(id: TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if TagManager.instance.local_id?(uri)
+    Conversation.find_by(uri: uri) || Conversation.create!(uri: uri)
+  end
+
+  def visibility_from_audience
+    if equals_or_includes?(@object['to'], ActivityPub::TagManager::COLLECTIONS[:public])
+      :public
+    elsif equals_or_includes?(@object['cc'], ActivityPub::TagManager::COLLECTIONS[:public])
+      :unlisted
+    elsif equals_or_includes?(@object['to'], @account.followers_url)
+      :private
+    else
+      :direct
+    end
+  end
+
+  def audience_includes?(account)
+    uri = ActivityPub::TagManager.instance.uri_for(account)
+    equals_or_includes?(@object['to'], uri) || equals_or_includes?(@object['cc'], uri)
+  end
+
+  def replied_to_status
+    return if @object['inReplyTo'].blank?
+    @replied_to_status ||= status_from_uri(@object['inReplyTo'])
+  end
+
+  def text_from_content
+    if @object['content'].present?
+      @object['content']
+    elsif language_map?
+      @object['contentMap'].values.first
+    end
+  end
+
+  def language_from_content
+    return nil unless language_map?
+    @object['contentMap'].keys.first
+  end
+
+  def language_map?
+    @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty?
+  end
+
+  def unsupported_object_type?
+    @object.is_a?(String) || !%w(Article Note).include?(@object['type'])
+  end
+
+  def unsupported_media_type?(mime_type)
+    mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
+  end
+
+  def skip_download?
+    return @skip_download if defined?(@skip_download)
+    @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
+  end
+end

+ 13 - 0
app/lib/activitypub/activity/delete.rb

@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Delete < ActivityPub::Activity
+  def perform
+    status = Status.find_by(uri: object_uri, account: @account)
+
+    if status.nil?
+      delete_later!(object_uri)
+    else
+      RemoveStatusService.new.call(status)
+    end
+  end
+end

+ 12 - 0
app/lib/activitypub/activity/follow.rb

@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Follow < ActivityPub::Activity
+  def perform
+    target_account = account_from_uri(object_uri)
+
+    return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id'])
+
+    follow = @account.follow!(target_account)
+    NotifyService.new.call(target_account, follow)
+  end
+end

+ 12 - 0
app/lib/activitypub/activity/like.rb

@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Like < ActivityPub::Activity
+  def perform
+    original_status = status_from_uri(object_uri)
+
+    return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id'])
+
+    favourite = original_status.favourites.where(account: @account).first_or_create!(account: @account)
+    NotifyService.new.call(original_status.account, favourite)
+  end
+end

+ 69 - 0
app/lib/activitypub/activity/undo.rb

@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Undo < ActivityPub::Activity
+  def perform
+    case @object['type']
+    when 'Announce'
+      undo_announce
+    when 'Follow'
+      undo_follow
+    when 'Like'
+      undo_like
+    when 'Block'
+      undo_block
+    end
+  end
+
+  private
+
+  def undo_announce
+    status = Status.find_by(uri: object_uri, account: @account)
+
+    if status.nil?
+      delete_later!(object_uri)
+    else
+      RemoveStatusService.new.call(status)
+    end
+  end
+
+  def undo_follow
+    target_account = account_from_uri(target_uri)
+
+    return if target_account.nil? || !target_account.local?
+
+    if @account.following?(target_account)
+      @account.unfollow!(target_account)
+    else
+      delete_later!(object_uri)
+    end
+  end
+
+  def undo_like
+    status = status_from_uri(target_uri)
+
+    return if status.nil? || !status.account.local?
+
+    if @account.favourited?(status)
+      favourite = status.favourites.where(account: @account).first
+      favourite&.destroy
+    else
+      delete_later!(object_uri)
+    end
+  end
+
+  def undo_block
+    target_account = account_from_uri(target_uri)
+
+    return if target_account.nil? || !target_account.local?
+
+    if @account.blocking?(target_account)
+      UnblockService.new.call(@account, target_account)
+    else
+      delete_later!(object_uri)
+    end
+  end
+
+  def target_uri
+    @target_uri ||= @object['object'].is_a?(String) ? @object['object'] : @object['object']['id']
+  end
+end

+ 17 - 0
app/lib/activitypub/activity/update.rb

@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Update < ActivityPub::Activity
+  def perform
+    case @object['type']
+    when 'Person'
+      update_account
+    end
+  end
+
+  private
+
+  def update_account
+    return if @account.uri != object_uri
+    ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object)
+  end
+end

+ 1 - 1
app/lib/activitypub/adapter.rb

@@ -7,7 +7,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
 
   def serializable_hash(options = nil)
     options = serialization_options(options)
-    serialized_hash = { '@context': 'https://www.w3.org/ns/activitystreams' }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options))
+    serialized_hash = { '@context': ActivityPub::TagManager::CONTEXT }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options))
     self.class.transform_key_casing!(serialized_hash, instance_options)
   end
 end

+ 25 - 0
app/lib/activitypub/tag_manager.rb

@@ -6,6 +6,8 @@ class ActivityPub::TagManager
   include Singleton
   include RoutingHelper
 
+  CONTEXT = 'https://www.w3.org/ns/activitystreams'
+
   COLLECTIONS = {
     public: 'https://www.w3.org/ns/activitystreams#Public',
   }.freeze
@@ -66,4 +68,27 @@ class ActivityPub::TagManager
 
     cc
   end
+
+  def local_uri?(uri)
+    host = Addressable::URI.parse(uri).normalized_host
+    ::TagManager.instance.local_domain?(host) || ::TagManager.instance.web_domain?(host)
+  end
+
+  def uri_to_local_id(uri, param = :id)
+    path_params = Rails.application.routes.recognize_path(uri)
+    path_params[param]
+  end
+
+  def uri_to_resource(uri, klass)
+    if local_uri?(uri)
+      case klass.name
+      when 'Account'
+        klass.find_local(uri_to_local_id(uri, :username))
+      else
+        klass.find_by(id: uri_to_local_id(uri))
+      end
+    else
+      klass.find_by(uri: uri)
+    end
+  end
 end

+ 2 - 0
app/models/concerns/remotable.rb

@@ -10,6 +10,8 @@ module Remotable
       alt_method_name = "reset_#{attachment_name}!".to_sym
 
       define_method method_name do |url|
+        return if url.blank?
+
         begin
           parsed_url = Addressable::URI.parse(url).normalize
         rescue Addressable::URI::InvalidURIError

+ 5 - 1
app/serializers/activitypub/accept_follow_serializer.rb

@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::AcceptFollowSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::FollowSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.target_account), '#accepts/follows/', object.id].join
+  end
+
   def type
     'Accept'
   end

+ 33 - 4
app/serializers/activitypub/actor_serializer.rb

@@ -5,10 +5,27 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
 
   attributes :id, :type, :following, :followers,
              :inbox, :outbox, :preferred_username,
-             :name, :summary, :icon, :image
+             :name, :summary, :url
 
   has_one :public_key, serializer: ActivityPub::PublicKeySerializer
 
+  class ImageSerializer < ActiveModel::Serializer
+    include RoutingHelper
+
+    attributes :type, :url
+
+    def type
+      'Image'
+    end
+
+    def url
+      full_asset_url(object.url(:original))
+    end
+  end
+
+  has_one :icon,  serializer: ImageSerializer, if: :avatar_exists?
+  has_one :image, serializer: ImageSerializer, if: :header_exists?
+
   def id
     account_url(object)
   end
@@ -26,7 +43,7 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
   end
 
   def inbox
-    nil
+    account_inbox_url(object)
   end
 
   def outbox
@@ -46,14 +63,26 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
   end
 
   def icon
-    full_asset_url(object.avatar.url(:original))
+    object.avatar
   end
 
   def image
-    full_asset_url(object.header.url(:original))
+    object.header
   end
 
   def public_key
     object
   end
+
+  def url
+    short_account_url(object)
+  end
+
+  def avatar_exists?
+    object.avatar.exists?
+  end
+
+  def header_exists?
+    object.header.exists?
+  end
 end

+ 5 - 1
app/serializers/activitypub/block_serializer.rb

@@ -1,9 +1,13 @@
 # frozen_string_literal: true
 
 class ActivityPub::BlockSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
   attribute :virtual_object, key: :object
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id].join
+  end
+
   def type
     'Block'
   end

+ 5 - 1
app/serializers/activitypub/delete_serializer.rb

@@ -1,9 +1,13 @@
 # frozen_string_literal: true
 
 class ActivityPub::DeleteSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
   attribute :virtual_object, key: :object
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object), '#delete'].join
+  end
+
   def type
     'Delete'
   end

+ 5 - 1
app/serializers/activitypub/follow_serializer.rb

@@ -1,9 +1,13 @@
 # frozen_string_literal: true
 
 class ActivityPub::FollowSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
   attribute :virtual_object, key: :object
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id].join
+  end
+
   def type
     'Follow'
   end

+ 5 - 1
app/serializers/activitypub/like_serializer.rb

@@ -1,9 +1,13 @@
 # frozen_string_literal: true
 
 class ActivityPub::LikeSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
   attribute :virtual_object, key: :object
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id].join
+  end
+
   def type
     'Like'
   end

+ 5 - 1
app/serializers/activitypub/reject_follow_serializer.rb

@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::RejectFollowSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::FollowSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.target_account), '#rejects/follows/', object.id].join
+  end
+
   def type
     'Reject'
   end

+ 5 - 1
app/serializers/activitypub/undo_block_serializer.rb

@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::UndoBlockSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::BlockSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id, '/undo'].join
+  end
+
   def type
     'Undo'
   end

+ 5 - 1
app/serializers/activitypub/undo_follow_serializer.rb

@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::UndoFollowSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::FollowSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id, '/undo'].join
+  end
+
   def type
     'Undo'
   end

+ 5 - 1
app/serializers/activitypub/undo_like_serializer.rb

@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::UndoLikeSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::LikeSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id, '/undo'].join
+  end
+
   def type
     'Undo'
   end

+ 5 - 1
app/serializers/activitypub/update_serializer.rb

@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::UpdateSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::ActorSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object), '#updates/', object.updated_at.to_i].join
+  end
+
   def type
     'Update'
   end

+ 57 - 0
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)
+    @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

+ 36 - 0
app/services/activitypub/fetch_remote_status_service.rb

@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchRemoteStatusService < BaseService
+  include JsonLdHelper
+
+  # Should be called when uri has already been checked for locality
+  def call(uri)
+    @json = fetch_resource(uri)
+
+    return unless supported_context? && expected_type?
+
+    attributed_to = first_of_value(@json['attributedTo'])
+    attributed_to = attributed_to['id'] if attributed_to.is_a?(Hash)
+
+    return unless trustworthy_attribution?(uri, attributed_to)
+
+    actor = ActivityPub::TagManager.instance.uri_to_resource(attributed_to, Account)
+    actor = ActivityPub::FetchRemoteAccountService.new.call(attributed_to) if actor.nil?
+
+    ActivityPub::Activity::Create.new({ 'object' => @json }, actor).perform
+  end
+
+  private
+
+  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?
+    %w(Note Article).include? @json['type']
+  end
+end

+ 84 - 0
app/services/activitypub/process_account_service.rb

@@ -0,0 +1,84 @@
+# 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)
+    @json     = json
+    @uri      = @json['id']
+    @username = username
+    @domain   = domain
+    @account  = Account.find_by(uri: @uri)
+
+    create_account if @account.nil?
+    update_account
+
+    @account
+  rescue Oj::ParseError
+    nil
+  end
+
+  private
+
+  def create_account
+    @account = Account.new
+    @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['sharedInbox'] || ''
+    @account.followers_url       = @json['followers'] || ''
+    @account.url                 = @json['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.save!
+  end
+
+  def image_url(key)
+    value = first_of_value(@json[key])
+
+    return if value.nil?
+    return @json[key]['url'] if @json[key].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 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

+ 38 - 0
app/services/activitypub/process_collection_service.rb

@@ -0,0 +1,38 @@
+# 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?
+
+    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 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
+end

+ 43 - 6
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,47 @@ 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)
+  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
+    json = fetch_resource(actor_url)
+
+    return unless supported_context?(json) && json['type'] == 'Person'
+
+    @account = ActivityPub::ProcessAccountService.new.call(@username, @domain, json)
+  rescue Oj::ParseError
+    nil
+  end
+
   def create_account
     Rails.logger.debug "Creating new remote account for #{@username}@#{@domain}"
 
@@ -81,6 +113,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 +144,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

+ 11 - 0
app/workers/activitypub/processing_worker.rb

@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class ActivityPub::ProcessingWorker
+  include Sidekiq::Worker
+
+  sidekiq_options backtrace: true
+
+  def perform(account_id, body)
+    ProcessCollectionService.new.call(body, Account.find(account_id))
+  end
+end

+ 17 - 0
app/workers/activitypub/thread_resolve_worker.rb

@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class ActivityPub::ThreadResolveWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull', retry: false
+
+  def perform(child_status_id, parent_uri)
+    child_status  = Status.find(child_status_id)
+    parent_status = ActivityPub::FetchRemoteStatusService.new.call(parent_uri)
+
+    return if parent_status.nil?
+
+    child_status.thread = parent_status
+    child_status.save!
+  end
+end

+ 1 - 0
config/initializers/inflections.rb

@@ -17,4 +17,5 @@ ActiveSupport::Inflector.inflections(:en) do |inflect|
   inflect.acronym 'ActivityPub'
   inflect.acronym 'PubSubHubbub'
   inflect.acronym 'ActivityStreams'
+  inflect.acronym 'JsonLd'
 end

+ 1 - 0
config/routes.rb

@@ -51,6 +51,7 @@ Rails.application.routes.draw do
     resource :follow, only: [:create], controller: :account_follow
     resource :unfollow, only: [:create], controller: :account_unfollow
     resource :outbox, only: [:show], module: :activitypub
+    resource :inbox, only: [:create], module: :activitypub
   end
 
   get '/@:username', to: 'accounts#show', as: :short_account

+ 7 - 0
spec/controllers/activitypub/inboxes_controller_spec.rb

@@ -0,0 +1,7 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::InboxesController, type: :controller do
+  describe 'POST #create' do
+    pending
+  end
+end

+ 19 - 0
spec/controllers/activitypub/outboxes_controller_spec.rb

@@ -0,0 +1,19 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::OutboxesController, type: :controller do
+  let!(:account) { Fabricate(:account) }
+
+  before do
+    Fabricate(:status, account: account)
+  end
+
+  describe 'GET #show' do
+    before do
+      get :show, params: { account_username: account.username }
+    end
+
+    it 'returns http success' do
+      expect(response).to have_http_status(:success)
+    end
+  end
+end

+ 35 - 0
spec/helpers/jsonld_helper_spec.rb

@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe JsonLdHelper do
+  describe '#equals_or_includes?' do
+    it 'returns true when value equals' do
+      expect(helper.equals_or_includes?('foo', 'foo')).to be true
+    end
+
+    it 'returns false when value does not equal' do
+      expect(helper.equals_or_includes?('foo', 'bar')).to be false
+    end
+
+    it 'returns true when value is included' do
+      expect(helper.equals_or_includes?(%w(foo baz), 'foo')).to be true
+    end
+
+    it 'returns false when value is not included' do
+      expect(helper.equals_or_includes?(%w(foo baz), 'bar')).to be false
+    end
+  end
+
+  describe '#first_of_value' do
+    pending
+  end
+
+  describe '#supported_context?' do
+    pending
+  end
+
+  describe '#fetch_resource' do
+    pending
+  end
+end

+ 29 - 0
spec/lib/activitypub/activity/announce_spec.rb

@@ -0,0 +1,29 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::Activity::Announce do
+  let(:sender)    { Fabricate(:account) }
+  let(:recipient) { Fabricate(:account) }
+  let(:status)    { Fabricate(:status, account: recipient) }
+
+  let(:json) do
+    {
+      '@context': 'https://www.w3.org/ns/activitystreams',
+      id: 'foo',
+      type: 'Announce',
+      actor: ActivityPub::TagManager.instance.uri_for(sender),
+      object: ActivityPub::TagManager.instance.uri_for(status),
+    }.with_indifferent_access
+  end
+
+  describe '#perform' do
+    subject { described_class.new(json, sender) }
+
+    before do
+      subject.perform
+    end
+
+    it 'creates a reblog by sender of status' do
+      expect(sender.reblogged?(status)).to be true
+    end
+  end
+end

+ 28 - 0
spec/lib/activitypub/activity/block_spec.rb

@@ -0,0 +1,28 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::Activity::Block do
+  let(:sender)    { Fabricate(:account) }
+  let(:recipient) { Fabricate(:account) }
+
+  let(:json) do
+    {
+      '@context': 'https://www.w3.org/ns/activitystreams',
+      id: 'foo',
+      type: 'Block',
+      actor: ActivityPub::TagManager.instance.uri_for(sender),
+      object: ActivityPub::TagManager.instance.uri_for(recipient),
+    }.with_indifferent_access
+  end
+
+  describe '#perform' do
+    subject { described_class.new(json, sender) }
+
+    before do
+      subject.perform
+    end
+
+    it 'creates a block from sender to recipient' do
+      expect(sender.blocking?(recipient)).to be true
+    end
+  end
+end

+ 221 - 0
spec/lib/activitypub/activity/create_spec.rb

@@ -0,0 +1,221 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::Activity::Create do
+  let(:sender) { Fabricate(:account, followers_url: 'http://example.com/followers') }
+
+  let(:json) do
+    {
+      '@context': 'https://www.w3.org/ns/activitystreams',
+      id: 'foo',
+      type: 'Create',
+      actor: ActivityPub::TagManager.instance.uri_for(sender),
+      object: object_json,
+    }.with_indifferent_access
+  end
+
+  subject { described_class.new(json, sender) }
+
+  before do
+    stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt'))
+  end
+
+  describe '#perform' do
+    before do
+      subject.perform
+    end
+
+    context 'standalone' do
+      let(:object_json) do
+        {
+          id: 'bar',
+          type: 'Note',
+          content: 'Lorem ipsum',
+        }
+      end
+
+      it 'creates status' do
+        status = sender.statuses.first
+
+        expect(status).to_not be_nil
+        expect(status.text).to eq 'Lorem ipsum'
+      end
+
+      it 'missing to/cc defaults to direct privacy' do
+        status = sender.statuses.first
+
+        expect(status).to_not be_nil
+        expect(status.visibility).to eq 'direct'
+      end
+    end
+
+    context 'public' do
+      let(:object_json) do
+        {
+          id: 'bar',
+          type: 'Note',
+          content: 'Lorem ipsum',
+          to: 'https://www.w3.org/ns/activitystreams#Public',
+        }
+      end
+
+      it 'creates status' do
+        status = sender.statuses.first
+
+        expect(status).to_not be_nil
+        expect(status.visibility).to eq 'public'
+      end
+    end
+
+    context 'unlisted' do
+      let(:object_json) do
+        {
+          id: 'bar',
+          type: 'Note',
+          content: 'Lorem ipsum',
+          cc: 'https://www.w3.org/ns/activitystreams#Public',
+        }
+      end
+
+      it 'creates status' do
+        status = sender.statuses.first
+
+        expect(status).to_not be_nil
+        expect(status.visibility).to eq 'unlisted'
+      end
+    end
+
+    context 'private' do
+      let(:object_json) do
+        {
+          id: 'bar',
+          type: 'Note',
+          content: 'Lorem ipsum',
+          to: 'http://example.com/followers',
+        }
+      end
+
+      it 'creates status' do
+        status = sender.statuses.first
+
+        expect(status).to_not be_nil
+        expect(status.visibility).to eq 'private'
+      end
+    end
+
+    context 'direct' do
+      let(:recipient) { Fabricate(:account) }
+
+      let(:object_json) do
+        {
+          id: 'bar',
+          type: 'Note',
+          content: 'Lorem ipsum',
+          to: ActivityPub::TagManager.instance.uri_for(recipient),
+        }
+      end
+
+      it 'creates status' do
+        status = sender.statuses.first
+
+        expect(status).to_not be_nil
+        expect(status.visibility).to eq 'direct'
+      end
+    end
+
+    context 'as a reply' do
+      let(:original_status) { Fabricate(:status) }
+
+      let(:object_json) do
+        {
+          id: 'bar',
+          type: 'Note',
+          content: 'Lorem ipsum',
+          inReplyTo: ActivityPub::TagManager.instance.uri_for(original_status),
+        }
+      end
+
+      it 'creates status' do
+        status = sender.statuses.first
+
+        expect(status).to_not be_nil
+        expect(status.thread).to eq original_status
+        expect(status.reply?).to be true
+        expect(status.in_reply_to_account).to eq original_status.account
+        expect(status.conversation).to eq original_status.conversation
+      end
+    end
+
+    context 'with mentions' do
+      let(:recipient) { Fabricate(:account) }
+
+      let(:object_json) do
+        {
+          id: 'bar',
+          type: 'Note',
+          content: 'Lorem ipsum',
+          tag: [
+            {
+              type: 'Mention',
+              href: ActivityPub::TagManager.instance.uri_for(recipient),
+            },
+          ],
+        }
+      end
+
+      it 'creates status' do
+        status = sender.statuses.first
+
+        expect(status).to_not be_nil
+        expect(status.mentions.map(&:account)).to include(recipient)
+      end
+    end
+
+    context 'with media attachments' do
+      let(:object_json) do
+        {
+          id: 'bar',
+          type: 'Note',
+          content: 'Lorem ipsum',
+          attachment: [
+            {
+              type: 'Document',
+              mime_type: 'image/png',
+              url: 'http://example.com/attachment.png',
+            },
+          ],
+        }
+      end
+
+      it 'creates status' do
+        status = sender.statuses.first
+
+        expect(status).to_not be_nil
+        expect(status.media_attachments.map(&:remote_url)).to include('http://example.com/attachment.png')
+      end
+    end
+
+    context 'with hashtags' do
+      let(:object_json) do
+        {
+          id: 'bar',
+          type: 'Note',
+          content: 'Lorem ipsum',
+          tag: [
+            {
+              type: 'Hashtag',
+              href: 'http://example.com/blah',
+              name: '#test',
+            },
+          ],
+        }
+      end
+
+      it 'creates status' do
+        status = sender.statuses.first
+
+        expect(status).to_not be_nil
+        expect(status.tags.map(&:name)).to include('test')
+      end
+    end
+  end
+end

+ 28 - 0
spec/lib/activitypub/activity/delete_spec.rb

@@ -0,0 +1,28 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::Activity::Delete do
+  let(:sender)    { Fabricate(:account) }
+  let(:status)    { Fabricate(:status, account: sender, uri: 'foobar') }
+
+  let(:json) do
+    {
+      '@context': 'https://www.w3.org/ns/activitystreams',
+      id: 'foo',
+      type: 'Delete',
+      actor: ActivityPub::TagManager.instance.uri_for(sender),
+      object: ActivityPub::TagManager.instance.uri_for(status),
+    }.with_indifferent_access
+  end
+
+  describe '#perform' do
+    subject { described_class.new(json, sender) }
+
+    before do
+      subject.perform
+    end
+
+    it 'deletes sender\'s status' do
+      expect(Status.find_by(id: status.id)).to be_nil
+    end
+  end
+end

+ 28 - 0
spec/lib/activitypub/activity/follow_spec.rb

@@ -0,0 +1,28 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::Activity::Follow do
+  let(:sender)    { Fabricate(:account) }
+  let(:recipient) { Fabricate(:account) }
+
+  let(:json) do
+    {
+      '@context': 'https://www.w3.org/ns/activitystreams',
+      id: 'foo',
+      type: 'Follow',
+      actor: ActivityPub::TagManager.instance.uri_for(sender),
+      object: ActivityPub::TagManager.instance.uri_for(recipient),
+    }.with_indifferent_access
+  end
+
+  describe '#perform' do
+    subject { described_class.new(json, sender) }
+
+    before do
+      subject.perform
+    end
+
+    it 'creates a follow from sender to recipient' do
+      expect(sender.following?(recipient)).to be true
+    end
+  end
+end

+ 29 - 0
spec/lib/activitypub/activity/like_spec.rb

@@ -0,0 +1,29 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::Activity::Like do
+  let(:sender)    { Fabricate(:account) }
+  let(:recipient) { Fabricate(:account) }
+  let(:status)    { Fabricate(:status, account: recipient) }
+
+  let(:json) do
+    {
+      '@context': 'https://www.w3.org/ns/activitystreams',
+      id: 'foo',
+      type: 'Like',
+      actor: ActivityPub::TagManager.instance.uri_for(sender),
+      object: ActivityPub::TagManager.instance.uri_for(status),
+    }.with_indifferent_access
+  end
+
+  describe '#perform' do
+    subject { described_class.new(json, sender) }
+
+    before do
+      subject.perform
+    end
+
+    it 'creates a favourite from sender to status' do
+      expect(sender.favourited?(status)).to be true
+    end
+  end
+end

+ 107 - 0
spec/lib/activitypub/activity/undo_spec.rb

@@ -0,0 +1,107 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::Activity::Undo do
+  let(:sender) { Fabricate(:account) }
+
+  let(:json) do
+    {
+      '@context': 'https://www.w3.org/ns/activitystreams',
+      id: 'foo',
+      type: 'Undo',
+      actor: ActivityPub::TagManager.instance.uri_for(sender),
+      object: object_json,
+    }.with_indifferent_access
+  end
+
+  subject { described_class.new(json, sender) }
+
+  describe '#perform' do
+    context 'with Announce' do
+      let(:status) { Fabricate(:status) }
+
+      let(:object_json) do
+        {
+          id: 'bar',
+          type: 'Announce',
+          actor: ActivityPub::TagManager.instance.uri_for(sender),
+          object: ActivityPub::TagManager.instance.uri_for(status),
+        }
+      end
+
+      before do
+        Fabricate(:status, reblog: status, account: sender, uri: 'bar')
+      end
+
+      it 'deletes the reblog' do
+        subject.perform
+        expect(sender.reblogged?(status)).to be false
+      end
+    end
+
+    context 'with Block' do
+      let(:recipient) { Fabricate(:account) }
+
+      let(:object_json) do
+        {
+          id: 'bar',
+          type: 'Block',
+          actor: ActivityPub::TagManager.instance.uri_for(sender),
+          object: ActivityPub::TagManager.instance.uri_for(recipient),
+        }
+      end
+
+      before do
+        sender.block!(recipient)
+      end
+
+      it 'deletes block from sender to recipient' do
+        subject.perform
+        expect(sender.blocking?(recipient)).to be false
+      end
+    end
+
+    context 'with Follow' do
+      let(:recipient) { Fabricate(:account) }
+
+      let(:object_json) do
+        {
+          id: 'bar',
+          type: 'Follow',
+          actor: ActivityPub::TagManager.instance.uri_for(sender),
+          object: ActivityPub::TagManager.instance.uri_for(recipient),
+        }
+      end
+
+      before do
+        sender.follow!(recipient)
+      end
+
+      it 'deletes follow from sender to recipient' do
+        subject.perform
+        expect(sender.following?(recipient)).to be false
+      end
+    end
+
+    context 'with Like' do
+      let(:status) { Fabricate(:status) }
+
+      let(:object_json) do
+        {
+          id: 'bar',
+          type: 'Like',
+          actor: ActivityPub::TagManager.instance.uri_for(sender),
+          object: ActivityPub::TagManager.instance.uri_for(status),
+        }
+      end
+
+      before do
+        Fabricate(:favourite, account: sender, status: status)
+      end
+
+      it 'deletes favourite from sender to status' do
+        subject.perform
+        expect(sender.favourited?(status)).to be false
+      end
+    end
+  end
+end

+ 41 - 0
spec/lib/activitypub/activity/update_spec.rb

@@ -0,0 +1,41 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::Activity::Update do
+  let!(:sender) { Fabricate(:account) }
+  
+  before do
+    sender.update!(uri: ActivityPub::TagManager.instance.uri_for(sender))
+  end
+
+  let(:modified_sender) do 
+    sender.dup.tap do |modified_sender|
+      modified_sender.display_name = 'Totally modified now'
+    end
+  end
+
+  let(:actor_json) do
+    ActiveModelSerializers::SerializableResource.new(modified_sender, serializer: ActivityPub::ActorSerializer, key_transform: :camel_lower).as_json
+  end
+
+  let(:json) do
+    {
+      '@context': 'https://www.w3.org/ns/activitystreams',
+      id: 'foo',
+      type: 'Update',
+      actor: ActivityPub::TagManager.instance.uri_for(sender),
+      object: actor_json,
+    }.with_indifferent_access
+  end
+
+  describe '#perform' do
+    subject { described_class.new(json, sender) }
+
+    before do
+      subject.perform
+    end
+
+    it 'updates profile' do
+      expect(sender.reload.display_name).to eq 'Totally modified now'
+    end
+  end
+end

+ 99 - 0
spec/lib/activitypub/tag_manager_spec.rb

@@ -0,0 +1,99 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::TagManager do
+  include RoutingHelper
+
+  subject { described_class.instance }
+
+  describe '#url_for' do
+    it 'returns a string' do
+      account = Fabricate(:account)
+      expect(subject.url_for(account)).to be_a String
+    end
+  end
+
+  describe '#uri_for' do
+    it 'returns a string' do
+      account = Fabricate(:account)
+      expect(subject.uri_for(account)).to be_a String
+    end
+  end
+
+  describe '#to' do
+    it 'returns public collection for public status' do
+      status = Fabricate(:status, visibility: :public)
+      expect(subject.to(status)).to eq ['https://www.w3.org/ns/activitystreams#Public']
+    end
+
+    it 'returns followers collection for unlisted status' do
+      status = Fabricate(:status, visibility: :unlisted)
+      expect(subject.to(status)).to eq [account_followers_url(status.account)]
+    end
+
+    it 'returns followers collection for private status' do
+      status = Fabricate(:status, visibility: :private)
+      expect(subject.to(status)).to eq [account_followers_url(status.account)]
+    end
+
+    it 'returns URIs of mentions for direct status' do
+      status    = Fabricate(:status, visibility: :direct)
+      mentioned = Fabricate(:account)
+      status.mentions.create(account: mentioned)
+      expect(subject.to(status)).to eq [subject.uri_for(mentioned)]
+    end
+  end
+
+  describe '#cc' do
+    it 'returns followers collection for public status' do
+      status = Fabricate(:status, visibility: :public)
+      expect(subject.cc(status)).to eq [account_followers_url(status.account)]
+    end
+
+    it 'returns public collection for unlisted status' do
+      status = Fabricate(:status, visibility: :unlisted)
+      expect(subject.cc(status)).to eq ['https://www.w3.org/ns/activitystreams#Public']
+    end
+
+    it 'returns empty array for private status' do
+      status = Fabricate(:status, visibility: :private)
+      expect(subject.cc(status)).to eq []
+    end
+
+    it 'returns empty array for direct status' do
+      status = Fabricate(:status, visibility: :direct)
+      expect(subject.cc(status)).to eq []
+    end
+
+    it 'returns URIs of mentions for non-direct status' do
+      status    = Fabricate(:status, visibility: :public)
+      mentioned = Fabricate(:account)
+      status.mentions.create(account: mentioned)
+      expect(subject.cc(status)).to include(subject.uri_for(mentioned))
+    end
+  end
+
+  describe '#local_uri?' do
+    it 'returns false for non-local URI' do
+      expect(subject.local_uri?('http://example.com/123')).to be false
+    end
+
+    it 'returns true for local URIs' do
+      account = Fabricate(:account)
+      expect(subject.local_uri?(subject.uri_for(account))).to be true
+    end
+  end
+
+  describe '#uri_to_local_id' do
+    it 'returns the local ID' do
+      account = Fabricate(:account)
+      expect(subject.uri_to_local_id(subject.uri_for(account), :username)).to eq account.username
+    end
+  end
+
+  describe '#uri_to_resource' do
+    it 'returns the local resource' do
+      account = Fabricate(:account)
+      expect(subject.uri_to_resource(subject.uri_for(account), Account)).to eq account
+    end
+  end
+end

+ 96 - 0
spec/services/activitypub/fetch_remote_account_service_spec.rb

@@ -0,0 +1,96 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::FetchRemoteAccountService do
+  subject { ActivityPub::FetchRemoteAccountService.new }
+
+  let!(:actor) do
+    {
+      '@context': 'https://www.w3.org/ns/activitystreams',
+      id: 'https://example.com/alice',
+      type: 'Person',
+      preferredUsername: 'alice',
+      name: 'Alice',
+      summary: 'Foo bar',
+    }
+  end
+
+  describe '#call' do
+    let(:account) { subject.call('https://example.com/alice') }
+
+    shared_examples 'sets profile data' do
+      it 'returns an account' do
+        expect(account).to be_an Account
+      end
+
+      it 'sets display name' do
+        expect(account.display_name).to eq 'Alice'
+      end
+
+      it 'sets note' do
+        expect(account.note).to eq 'Foo bar'
+      end
+
+      it 'sets URL' do
+        expect(account.url).to eq 'https://example.com/alice'
+      end
+    end
+
+    context 'when URI and WebFinger share the same host' do
+      let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
+
+      before do
+        stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
+        stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
+      end
+
+      it 'fetches resource' do
+        account
+        expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once
+      end
+
+      it 'looks up webfinger' do
+        account
+        expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once
+      end
+
+      it 'sets username and domain from webfinger' do
+        expect(account.username).to eq 'alice'
+        expect(account.domain).to eq 'example.com'
+      end
+
+      include_examples 'sets profile data'
+    end
+
+    context 'when WebFinger presents different domain than URI' do
+      let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
+
+      before do
+        stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
+        stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
+        stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
+      end
+
+      it 'fetches resource' do
+        account
+        expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once
+      end
+
+      it 'looks up webfinger' do
+        account
+        expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once
+      end
+
+      it 'looks up "redirected" webfinger' do
+        account
+        expect(a_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af')).to have_been_made.once
+      end
+
+      it 'sets username and domain from final webfinger' do
+        expect(account.username).to eq 'alice'
+        expect(account.domain).to eq 'iscool.af'
+      end
+
+      include_examples 'sets profile data'
+    end
+  end
+end

+ 5 - 0
spec/services/activitypub/fetch_remote_status_service_spec.rb

@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::FetchRemoteStatusService do
+  pending
+end

+ 5 - 0
spec/services/activitypub/process_account_service_spec.rb

@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::ProcessAccountService do
+  pending
+end

+ 9 - 0
spec/services/activitypub/process_collection_service_spec.rb

@@ -0,0 +1,9 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::ProcessCollectionService do
+  subject { ActivityPub::ProcessCollectionService.new }
+
+  describe '#call' do
+    pending
+  end
+end