about summary refs log tree commit diff
path: root/app/lib/activitypub
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2017-08-08 21:52:15 +0200
committerGitHub <noreply@github.com>2017-08-08 21:52:15 +0200
commitdd7ef0dc41584089a97444d8192bc61505108e6c (patch)
tree5bf440456b4f385e7202834a922975c0a188af7b /app/lib/activitypub
parentdcbc1af38a3ddc289783d9e9021690692bc1438e (diff)
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
Diffstat (limited to 'app/lib/activitypub')
-rw-r--r--app/lib/activitypub/activity.rb109
-rw-r--r--app/lib/activitypub/activity/announce.rb14
-rw-r--r--app/lib/activitypub/activity/block.rb12
-rw-r--r--app/lib/activitypub/activity/create.rb148
-rw-r--r--app/lib/activitypub/activity/delete.rb13
-rw-r--r--app/lib/activitypub/activity/follow.rb12
-rw-r--r--app/lib/activitypub/activity/like.rb12
-rw-r--r--app/lib/activitypub/activity/undo.rb69
-rw-r--r--app/lib/activitypub/activity/update.rb17
-rw-r--r--app/lib/activitypub/adapter.rb2
-rw-r--r--app/lib/activitypub/tag_manager.rb25
11 files changed, 432 insertions, 1 deletions
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
new file mode 100644
index 000000000..d1b81a582
--- /dev/null
+++ b/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
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
new file mode 100644
index 000000000..decf8f960
--- /dev/null
+++ b/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
diff --git a/app/lib/activitypub/activity/block.rb b/app/lib/activitypub/activity/block.rb
new file mode 100644
index 000000000..e6b6c837b
--- /dev/null
+++ b/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
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
new file mode 100644
index 000000000..4c4049bc6
--- /dev/null
+++ b/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
diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb
new file mode 100644
index 000000000..23f3430fb
--- /dev/null
+++ b/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
diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb
new file mode 100644
index 000000000..7918b5108
--- /dev/null
+++ b/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
diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb
new file mode 100644
index 000000000..c24527597
--- /dev/null
+++ b/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
diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb
new file mode 100644
index 000000000..078e97ed4
--- /dev/null
+++ b/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
diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb
new file mode 100644
index 000000000..0134b4015
--- /dev/null
+++ b/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
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index 0a70207bc..e038136c0 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/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
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index ec42bcad3..96e610b6d 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/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