diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2017-08-08 21:52:15 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-08-08 21:52:15 +0200 |
commit | dd7ef0dc41584089a97444d8192bc61505108e6c (patch) | |
tree | 5bf440456b4f385e7202834a922975c0a188af7b /app/lib/activitypub | |
parent | dcbc1af38a3ddc289783d9e9021690692bc1438e (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.rb | 109 | ||||
-rw-r--r-- | app/lib/activitypub/activity/announce.rb | 14 | ||||
-rw-r--r-- | app/lib/activitypub/activity/block.rb | 12 | ||||
-rw-r--r-- | app/lib/activitypub/activity/create.rb | 148 | ||||
-rw-r--r-- | app/lib/activitypub/activity/delete.rb | 13 | ||||
-rw-r--r-- | app/lib/activitypub/activity/follow.rb | 12 | ||||
-rw-r--r-- | app/lib/activitypub/activity/like.rb | 12 | ||||
-rw-r--r-- | app/lib/activitypub/activity/undo.rb | 69 | ||||
-rw-r--r-- | app/lib/activitypub/activity/update.rb | 17 | ||||
-rw-r--r-- | app/lib/activitypub/adapter.rb | 2 | ||||
-rw-r--r-- | app/lib/activitypub/tag_manager.rb | 25 |
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 |