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
							
								
									cbcac23679
								
							
						
					
					
						commit
						f18739fd60
					
				
					 50 changed files with 1652 additions and 21 deletions
				
			
		
							
								
								
									
										30
									
								
								app/controllers/activitypub/inboxes_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								app/controllers/activitypub/inboxes_controller.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								app/helpers/jsonld_helper.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								app/helpers/jsonld_helper.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								app/lib/activitypub/activity.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								app/lib/activitypub/activity.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								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 | ||||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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
									
								
								app/services/activitypub/fetch_remote_account_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								app/services/activitypub/fetch_remote_account_service.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								app/services/activitypub/fetch_remote_status_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								app/services/activitypub/fetch_remote_status_service.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								app/services/activitypub/process_account_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								app/services/activitypub/process_account_service.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								app/services/activitypub/process_collection_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								app/services/activitypub/process_collection_service.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
|  | @ -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
									
								
								app/workers/activitypub/processing_worker.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/workers/activitypub/processing_worker.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								app/workers/activitypub/thread_resolve_worker.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/workers/activitypub/thread_resolve_worker.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
|  | @ -17,4 +17,5 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| | |||
|   inflect.acronym 'ActivityPub' | ||||
|   inflect.acronym 'PubSubHubbub' | ||||
|   inflect.acronym 'ActivityStreams' | ||||
|   inflect.acronym 'JsonLd' | ||||
| end | ||||
|  |  | |||
|  | @ -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
									
								
								spec/controllers/activitypub/inboxes_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								spec/controllers/activitypub/inboxes_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | |||
| require 'rails_helper' | ||||
| 
 | ||||
| RSpec.describe ActivityPub::InboxesController, type: :controller do | ||||
|   describe 'POST #create' do | ||||
|     pending | ||||
|   end | ||||
| end | ||||
							
								
								
									
										19
									
								
								spec/controllers/activitypub/outboxes_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								spec/controllers/activitypub/outboxes_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								spec/helpers/jsonld_helper_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								spec/helpers/jsonld_helper_spec.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								spec/lib/activitypub/activity/announce_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								spec/lib/activitypub/activity/announce_spec.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								spec/lib/activitypub/activity/block_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								spec/lib/activitypub/activity/block_spec.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								spec/lib/activitypub/activity/create_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										221
									
								
								spec/lib/activitypub/activity/create_spec.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								spec/lib/activitypub/activity/delete_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								spec/lib/activitypub/activity/delete_spec.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								spec/lib/activitypub/activity/follow_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								spec/lib/activitypub/activity/follow_spec.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								spec/lib/activitypub/activity/like_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								spec/lib/activitypub/activity/like_spec.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								spec/lib/activitypub/activity/undo_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								spec/lib/activitypub/activity/undo_spec.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								spec/lib/activitypub/activity/update_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								spec/lib/activitypub/activity/update_spec.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								spec/lib/activitypub/tag_manager_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								spec/lib/activitypub/tag_manager_spec.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
|  | @ -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 | ||||
|  | @ -0,0 +1,5 @@ | |||
| require 'rails_helper' | ||||
| 
 | ||||
| RSpec.describe ActivityPub::FetchRemoteStatusService do | ||||
|   pending | ||||
| end | ||||
|  | @ -0,0 +1,5 @@ | |||
| require 'rails_helper' | ||||
| 
 | ||||
| RSpec.describe ActivityPub::ProcessAccountService do | ||||
|   pending | ||||
| end | ||||
|  | @ -0,0 +1,9 @@ | |||
| require 'rails_helper' | ||||
| 
 | ||||
| RSpec.describe ActivityPub::ProcessCollectionService do | ||||
|   subject { ActivityPub::ProcessCollectionService.new } | ||||
| 
 | ||||
|   describe '#call' do | ||||
|     pending | ||||
|   end | ||||
| end | ||||
		Loading…
	
		Reference in a new issue