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
This commit is contained in:
		
					parent
					
						
							
								dcbc1af38a
							
						
					
				
			
			
				commit
				
					
						dd7ef0dc41
					
				
			
		
					 50 changed files with 1652 additions and 21 deletions
				
			
		
							
								
								
									
										14
									
								
								app/lib/activitypub/activity/announce.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								app/lib/activitypub/activity/announce.rb
									
										
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								app/lib/activitypub/activity/block.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								app/lib/activitypub/activity/block.rb
									
										
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								app/lib/activitypub/activity/create.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								app/lib/activitypub/activity/create.rb
									
										
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								app/lib/activitypub/activity/delete.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								app/lib/activitypub/activity/delete.rb
									
										
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								app/lib/activitypub/activity/follow.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								app/lib/activitypub/activity/follow.rb
									
										
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								app/lib/activitypub/activity/like.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								app/lib/activitypub/activity/like.rb
									
										
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								app/lib/activitypub/activity/undo.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								app/lib/activitypub/activity/undo.rb
									
										
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								app/lib/activitypub/activity/update.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/lib/activitypub/activity/update.rb
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue