diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..0f49b43999571eb212d267aacd2266afe553aefe --- /dev/null +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class ActivityPub::InboxesController < Api::BaseController + include SignatureVerification + + before_action :set_account + + def create + if signed_request_account + process_payload + head 201 + else + head 202 + end + end + + private + + def set_account + @account = Account.find_local!(params[:account_username]) + end + + def body + @body ||= request.body.read + end + + def process_payload + ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body.force_encoding('UTF-8')) + end +end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb new file mode 100644 index 0000000000000000000000000000000000000000..b0db025bc5269641425281468e7810a8ff929529 --- /dev/null +++ b/app/helpers/jsonld_helper.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module JsonLdHelper + def equals_or_includes?(haystack, needle) + haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle + end + + def first_of_value(value) + value.is_a?(Array) ? value.first : value + end + + def supported_context?(json) + equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) + end + + def fetch_resource(uri) + response = build_request(uri).perform + return if response.code != 200 + Oj.load(response.to_s, mode: :strict) + rescue Oj::ParseError + nil + end + + private + + def build_request(uri) + request = Request.new(:get, uri) + request.add_headers('Accept' => 'application/activity+json') + request + end +end diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb new file mode 100644 index 0000000000000000000000000000000000000000..d1b81a58274a76ea2f0b332a65756d123ebe4424 --- /dev/null +++ b/app/lib/activitypub/activity.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +class ActivityPub::Activity + include JsonLdHelper + + def initialize(json, account) + @json = json + @account = account + @object = @json['object'] + end + + def perform + raise NotImplementedError + end + + class << self + def factory(json, account) + @json = json + klass&.new(json, account) + end + + private + + def klass + case @json['type'] + when 'Create' + ActivityPub::Activity::Create + when 'Announce' + ActivityPub::Activity::Announce + when 'Delete' + ActivityPub::Activity::Delete + when 'Follow' + ActivityPub::Activity::Follow + when 'Like' + ActivityPub::Activity::Like + when 'Block' + ActivityPub::Activity::Block + when 'Update' + ActivityPub::Activity::Update + when 'Undo' + ActivityPub::Activity::Undo + end + end + end + + protected + + def status_from_uri(uri) + ActivityPub::TagManager.instance.uri_to_resource(uri, Status) + end + + def account_from_uri(uri) + ActivityPub::TagManager.instance.uri_to_resource(uri, Account) + end + + def object_uri + @object_uri ||= @object.is_a?(String) ? @object : @object['id'] + end + + def redis + Redis.current + end + + def distribute(status) + notify_about_reblog(status) if reblog_of_local_account?(status) + notify_about_mentions(status) + crawl_links(status) + distribute_to_followers(status) + end + + def reblog_of_local_account?(status) + status.reblog? && status.reblog.account.local? + end + + def notify_about_reblog(status) + NotifyService.new.call(status.reblog.account, status) + end + + def notify_about_mentions(status) + status.mentions.includes(:account).each do |mention| + next unless mention.account.local? && audience_includes?(mention.account) + NotifyService.new.call(mention.account, mention) + end + end + + def crawl_links(status) + return if status.spoiler_text? + LinkCrawlWorker.perform_async(status.id) + end + + def distribute_to_followers(status) + DistributionWorker.perform_async(status.id) + end + + def delete_arrived_first?(uri) + key = "delete_upon_arrival:#{@account.id}:#{uri}" + + if redis.exists(key) + redis.del(key) + true + else + false + end + end + + def delete_later!(uri) + redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri) + end +end diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb new file mode 100644 index 0000000000000000000000000000000000000000..decf8f9601c46266ff8bf701afdb5e2e0f74c5a2 --- /dev/null +++ b/app/lib/activitypub/activity/announce.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Announce < ActivityPub::Activity + def perform + original_status = status_from_uri(object_uri) + original_status = ActivityPub::FetchRemoteStatusService.new.call(object_uri) if original_status.nil? + + return if original_status.nil? || delete_arrived_first?(@json['id']) + + status = Status.create!(account: @account, reblog: original_status, uri: @json['id']) + distribute(status) + status + end +end diff --git a/app/lib/activitypub/activity/block.rb b/app/lib/activitypub/activity/block.rb new file mode 100644 index 0000000000000000000000000000000000000000..e6b6c837ba941a4c58db2eedd9fd7be23a40b3de --- /dev/null +++ b/app/lib/activitypub/activity/block.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Block < ActivityPub::Activity + def perform + target_account = account_from_uri(object_uri) + + return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) + + UnfollowService.new.call(target_account, @account) if target_account.following?(@account) + @account.block!(target_account) + end +end diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb new file mode 100644 index 0000000000000000000000000000000000000000..4c4049bc697fdedd30a3f49766f6f71d41317b58 --- /dev/null +++ b/app/lib/activitypub/activity/create.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Create < ActivityPub::Activity + def perform + return if delete_arrived_first?(object_uri) || unsupported_object_type? + + status = Status.find_by(uri: object_uri) + + return status unless status.nil? + + ApplicationRecord.transaction do + status = Status.create!(status_params) + + process_tags(status) + process_attachments(status) + end + + resolve_thread(status) + distribute(status) + + status + end + + private + + def status_params + { + uri: @object['id'], + url: @object['url'], + account: @account, + text: text_from_content || '', + language: language_from_content, + spoiler_text: @object['summary'] || '', + created_at: @object['published'] || Time.now.utc, + reply: @object['inReplyTo'].present?, + sensitive: @object['sensitive'] || false, + visibility: visibility_from_audience, + thread: replied_to_status, + conversation: conversation_from_uri(@object['_:conversation']), + } + end + + def process_tags(status) + return unless @object['tag'].is_a?(Array) + + @object['tag'].each do |tag| + case tag['type'] + when 'Hashtag' + process_hashtag tag, status + when 'Mention' + process_mention tag, status + end + end + end + + def process_hashtag(tag, status) + hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase + hashtag = Tag.where(name: hashtag).first_or_initialize(name: hashtag) + + status.tags << hashtag + end + + def process_mention(tag, status) + account = account_from_uri(tag['href']) + account = ActivityPub::FetchRemoteAccountService.new.call(tag['href']) if account.nil? + return if account.nil? + account.mentions.create(status: status) + end + + def process_attachments(status) + return unless @object['attachment'].is_a?(Array) + + @object['attachment'].each do |attachment| + next if unsupported_media_type?(attachment['mediaType']) + + href = Addressable::URI.parse(attachment['url']).normalize.to_s + media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href) + + next if skip_download? + + media_attachment.file_remote_url = href + media_attachment.save + end + end + + def resolve_thread(status) + return unless status.reply? && status.thread.nil? + ActivityPub::ThreadResolveWorker.perform_async(status.id, @object['inReplyTo']) + end + + def conversation_from_uri(uri) + return nil if uri.nil? + return Conversation.find_by(id: TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if TagManager.instance.local_id?(uri) + Conversation.find_by(uri: uri) || Conversation.create!(uri: uri) + end + + def visibility_from_audience + if equals_or_includes?(@object['to'], ActivityPub::TagManager::COLLECTIONS[:public]) + :public + elsif equals_or_includes?(@object['cc'], ActivityPub::TagManager::COLLECTIONS[:public]) + :unlisted + elsif equals_or_includes?(@object['to'], @account.followers_url) + :private + else + :direct + end + end + + def audience_includes?(account) + uri = ActivityPub::TagManager.instance.uri_for(account) + equals_or_includes?(@object['to'], uri) || equals_or_includes?(@object['cc'], uri) + end + + def replied_to_status + return if @object['inReplyTo'].blank? + @replied_to_status ||= status_from_uri(@object['inReplyTo']) + end + + def text_from_content + if @object['content'].present? + @object['content'] + elsif language_map? + @object['contentMap'].values.first + end + end + + def language_from_content + return nil unless language_map? + @object['contentMap'].keys.first + end + + def language_map? + @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty? + end + + def unsupported_object_type? + @object.is_a?(String) || !%w(Article Note).include?(@object['type']) + end + + def unsupported_media_type?(mime_type) + mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type) + end + + def skip_download? + return @skip_download if defined?(@skip_download) + @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media? + end +end diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb new file mode 100644 index 0000000000000000000000000000000000000000..23f3430fb6e6de81c67975151916015ac694dc3b --- /dev/null +++ b/app/lib/activitypub/activity/delete.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Delete < ActivityPub::Activity + def perform + status = Status.find_by(uri: object_uri, account: @account) + + if status.nil? + delete_later!(object_uri) + else + RemoveStatusService.new.call(status) + end + end +end diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb new file mode 100644 index 0000000000000000000000000000000000000000..7918b5108a8c1434d713c21d86ca908ab2557da5 --- /dev/null +++ b/app/lib/activitypub/activity/follow.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Follow < ActivityPub::Activity + def perform + target_account = account_from_uri(object_uri) + + return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) + + follow = @account.follow!(target_account) + NotifyService.new.call(target_account, follow) + end +end diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb new file mode 100644 index 0000000000000000000000000000000000000000..c24527597f0a844063ee9a20672e0d9894f8daff --- /dev/null +++ b/app/lib/activitypub/activity/like.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Like < ActivityPub::Activity + def perform + original_status = status_from_uri(object_uri) + + return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) + + favourite = original_status.favourites.where(account: @account).first_or_create!(account: @account) + NotifyService.new.call(original_status.account, favourite) + end +end diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb new file mode 100644 index 0000000000000000000000000000000000000000..078e97ed491ee38410229c3ce472675966bf1cc6 --- /dev/null +++ b/app/lib/activitypub/activity/undo.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Undo < ActivityPub::Activity + def perform + case @object['type'] + when 'Announce' + undo_announce + when 'Follow' + undo_follow + when 'Like' + undo_like + when 'Block' + undo_block + end + end + + private + + def undo_announce + status = Status.find_by(uri: object_uri, account: @account) + + if status.nil? + delete_later!(object_uri) + else + RemoveStatusService.new.call(status) + end + end + + def undo_follow + target_account = account_from_uri(target_uri) + + return if target_account.nil? || !target_account.local? + + if @account.following?(target_account) + @account.unfollow!(target_account) + else + delete_later!(object_uri) + end + end + + def undo_like + status = status_from_uri(target_uri) + + return if status.nil? || !status.account.local? + + if @account.favourited?(status) + favourite = status.favourites.where(account: @account).first + favourite&.destroy + else + delete_later!(object_uri) + end + end + + def undo_block + target_account = account_from_uri(target_uri) + + return if target_account.nil? || !target_account.local? + + if @account.blocking?(target_account) + UnblockService.new.call(@account, target_account) + else + delete_later!(object_uri) + end + end + + def target_uri + @target_uri ||= @object['object'].is_a?(String) ? @object['object'] : @object['object']['id'] + end +end diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb new file mode 100644 index 0000000000000000000000000000000000000000..0134b4015ffe5973bf4fe930dce5959e9d6ba95d --- /dev/null +++ b/app/lib/activitypub/activity/update.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Update < ActivityPub::Activity + def perform + case @object['type'] + when 'Person' + update_account + end + end + + private + + def update_account + return if @account.uri != object_uri + ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object) + end +end diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index 0a70207bc47135d30f65c09c66f95ff2415ff90e..e038136c088346bf7b7a5013eb90df2928c1fe8d 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -7,7 +7,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base def serializable_hash(options = nil) options = serialization_options(options) - serialized_hash = { '@context': 'https://www.w3.org/ns/activitystreams' }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options)) + serialized_hash = { '@context': ActivityPub::TagManager::CONTEXT }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options)) self.class.transform_key_casing!(serialized_hash, instance_options) end end diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index ec42bcad30f50dc1d78fb45b22dd8e6744d07ee2..96e610b6da13d90cadf4f842ff82769b10a743da 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -6,6 +6,8 @@ class ActivityPub::TagManager include Singleton include RoutingHelper + CONTEXT = 'https://www.w3.org/ns/activitystreams' + COLLECTIONS = { public: 'https://www.w3.org/ns/activitystreams#Public', }.freeze @@ -66,4 +68,27 @@ class ActivityPub::TagManager cc end + + def local_uri?(uri) + host = Addressable::URI.parse(uri).normalized_host + ::TagManager.instance.local_domain?(host) || ::TagManager.instance.web_domain?(host) + end + + def uri_to_local_id(uri, param = :id) + path_params = Rails.application.routes.recognize_path(uri) + path_params[param] + end + + def uri_to_resource(uri, klass) + if local_uri?(uri) + case klass.name + when 'Account' + klass.find_local(uri_to_local_id(uri, :username)) + else + klass.find_by(id: uri_to_local_id(uri)) + end + else + klass.find_by(uri: uri) + end + end end diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb index 1bd87a642ef3112b74c4d6896b5b9d899b051837..270043a9ef2d696aacba82c1fd84827c96611ec7 100644 --- a/app/models/concerns/remotable.rb +++ b/app/models/concerns/remotable.rb @@ -10,6 +10,8 @@ module Remotable alt_method_name = "reset_#{attachment_name}!".to_sym define_method method_name do |url| + return if url.blank? + begin parsed_url = Addressable::URI.parse(url).normalize rescue Addressable::URI::InvalidURIError diff --git a/app/serializers/activitypub/accept_follow_serializer.rb b/app/serializers/activitypub/accept_follow_serializer.rb index ce900bc7803ea4e8ae0317b01b80ae1acfd56429..3e23591a527492ee5b2869bf1b0293e9d2ffef21 100644 --- a/app/serializers/activitypub/accept_follow_serializer.rb +++ b/app/serializers/activitypub/accept_follow_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::AcceptFollowSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::FollowSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object.target_account), '#accepts/follows/', object.id].join + end + def type 'Accept' end diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index f5e626d7370ad228a21d05599c97453b78bac520..8a119603d061c29bc0a5291427fcc62f68f8056f 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -5,10 +5,27 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer attributes :id, :type, :following, :followers, :inbox, :outbox, :preferred_username, - :name, :summary, :icon, :image + :name, :summary, :url has_one :public_key, serializer: ActivityPub::PublicKeySerializer + class ImageSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :type, :url + + def type + 'Image' + end + + def url + full_asset_url(object.url(:original)) + end + end + + has_one :icon, serializer: ImageSerializer, if: :avatar_exists? + has_one :image, serializer: ImageSerializer, if: :header_exists? + def id account_url(object) end @@ -26,7 +43,7 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer end def inbox - nil + account_inbox_url(object) end def outbox @@ -46,14 +63,26 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer end def icon - full_asset_url(object.avatar.url(:original)) + object.avatar end def image - full_asset_url(object.header.url(:original)) + object.header end def public_key object end + + def url + short_account_url(object) + end + + def avatar_exists? + object.avatar.exists? + end + + def header_exists? + object.header.exists? + end end diff --git a/app/serializers/activitypub/block_serializer.rb b/app/serializers/activitypub/block_serializer.rb index a001b213b83cf8b7a34819f6a8faa7e9c38a5a95..b3bd9f868ec07d7b0318039c5568edc51458b5e3 100644 --- a/app/serializers/activitypub/block_serializer.rb +++ b/app/serializers/activitypub/block_serializer.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true class ActivityPub::BlockSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor attribute :virtual_object, key: :object + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id].join + end + def type 'Block' end diff --git a/app/serializers/activitypub/delete_serializer.rb b/app/serializers/activitypub/delete_serializer.rb index 77098b1b0c6c5189e99f2a81dd5da4263e6636d1..b49268d72a31dfb9f3d0b8616b138f619eb1228b 100644 --- a/app/serializers/activitypub/delete_serializer.rb +++ b/app/serializers/activitypub/delete_serializer.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true class ActivityPub::DeleteSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor attribute :virtual_object, key: :object + def id + [ActivityPub::TagManager.instance.uri_for(object), '#delete'].join + end + def type 'Delete' end diff --git a/app/serializers/activitypub/follow_serializer.rb b/app/serializers/activitypub/follow_serializer.rb index 1953a2d7b82b10a504c7164ece23fe555f57d4d8..86c9992fe32684a3b58907fb097c2a10192a61b1 100644 --- a/app/serializers/activitypub/follow_serializer.rb +++ b/app/serializers/activitypub/follow_serializer.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true class ActivityPub::FollowSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor attribute :virtual_object, key: :object + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id].join + end + def type 'Follow' end diff --git a/app/serializers/activitypub/like_serializer.rb b/app/serializers/activitypub/like_serializer.rb index 4226913f509c18043e1c67ee6e9fe2d02b43bd43..c1a7ff6f63743e210afd128004d2523c5af0f7af 100644 --- a/app/serializers/activitypub/like_serializer.rb +++ b/app/serializers/activitypub/like_serializer.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true class ActivityPub::LikeSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor attribute :virtual_object, key: :object + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id].join + end + def type 'Like' end diff --git a/app/serializers/activitypub/reject_follow_serializer.rb b/app/serializers/activitypub/reject_follow_serializer.rb index 28584d6271f226b40504134405d584b24e238771..7814f4f5759d450b8a7d58c1b0a9345c767634f2 100644 --- a/app/serializers/activitypub/reject_follow_serializer.rb +++ b/app/serializers/activitypub/reject_follow_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::RejectFollowSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::FollowSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object.target_account), '#rejects/follows/', object.id].join + end + def type 'Reject' end diff --git a/app/serializers/activitypub/undo_block_serializer.rb b/app/serializers/activitypub/undo_block_serializer.rb index f71faa72962b813007486179e0b57239b3006685..2f43d8402a38a259ade75353940c5a18f96bc72e 100644 --- a/app/serializers/activitypub/undo_block_serializer.rb +++ b/app/serializers/activitypub/undo_block_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::UndoBlockSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::BlockSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id, '/undo'].join + end + def type 'Undo' end diff --git a/app/serializers/activitypub/undo_follow_serializer.rb b/app/serializers/activitypub/undo_follow_serializer.rb index fe91f5f1c4d163f15bace145090add52a711cfa2..e5b7f143d793428a7859cbe0439f5606b901a4bf 100644 --- a/app/serializers/activitypub/undo_follow_serializer.rb +++ b/app/serializers/activitypub/undo_follow_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::UndoFollowSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::FollowSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id, '/undo'].join + end + def type 'Undo' end diff --git a/app/serializers/activitypub/undo_like_serializer.rb b/app/serializers/activitypub/undo_like_serializer.rb index db9cd1d0de4b18534d55c004c21d6e5d67e9af6f..25f4ccaaed5ac120fc777f165f199b305c7b08bd 100644 --- a/app/serializers/activitypub/undo_like_serializer.rb +++ b/app/serializers/activitypub/undo_like_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::UndoLikeSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::LikeSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id, '/undo'].join + end + def type 'Undo' end diff --git a/app/serializers/activitypub/update_serializer.rb b/app/serializers/activitypub/update_serializer.rb index 322305da8c783bcd27a758b47f28b24d8cef780b..ebc667d9630d17e9252e84d9996b1d1f211d6c74 100644 --- a/app/serializers/activitypub/update_serializer.rb +++ b/app/serializers/activitypub/update_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::UpdateSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::ActorSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object), '#updates/', object.updated_at.to_i].join + end + def type 'Update' end diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..e443b94638a88eebf316385b25910f1a9d94608b --- /dev/null +++ b/app/services/activitypub/fetch_remote_account_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class ActivityPub::FetchRemoteAccountService < BaseService + include JsonLdHelper + + # Should be called when uri has already been checked for locality + # Does a WebFinger roundtrip on each call + def call(uri) + @json = fetch_resource(uri) + + return unless supported_context? && expected_type? + + @uri = @json['id'] + @username = @json['preferredUsername'] + @domain = Addressable::URI.parse(uri).normalized_host + + return unless verified_webfinger? + + ActivityPub::ProcessAccountService.new.call(@username, @domain, @json) + rescue Oj::ParseError + nil + end + + private + + def verified_webfinger? + webfinger = Goldfinger.finger("acct:#{@username}@#{@domain}") + confirmed_username, confirmed_domain = split_acct(webfinger.subject) + + return true if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? + + webfinger = Goldfinger.finger("acct:#{confirmed_username}@#{confirmed_domain}") + confirmed_username, confirmed_domain = split_acct(webfinger.subject) + self_reference = webfinger.link('self') + + return false if self_reference&.href != @uri + + @username = confirmed_username + @domain = confirmed_domain + + true + rescue Goldfinger::Error + false + end + + def split_acct(acct) + acct.gsub(/\Aacct:/, '').split('@') + end + + def supported_context? + super(@json) + end + + def expected_type? + @json['type'] == 'Person' + end +end diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..80305c53dce0aed127ade26f441d97a68639d5f0 --- /dev/null +++ b/app/services/activitypub/fetch_remote_status_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class ActivityPub::FetchRemoteStatusService < BaseService + include JsonLdHelper + + # Should be called when uri has already been checked for locality + def call(uri) + @json = fetch_resource(uri) + + return unless supported_context? && expected_type? + + attributed_to = first_of_value(@json['attributedTo']) + attributed_to = attributed_to['id'] if attributed_to.is_a?(Hash) + + return unless trustworthy_attribution?(uri, attributed_to) + + actor = ActivityPub::TagManager.instance.uri_to_resource(attributed_to, Account) + actor = ActivityPub::FetchRemoteAccountService.new.call(attributed_to) if actor.nil? + + ActivityPub::Activity::Create.new({ 'object' => @json }, actor).perform + end + + private + + def trustworthy_attribution?(uri, attributed_to) + Addressable::URI.parse(uri).normalized_host.casecmp(Addressable::URI.parse(attributed_to).normalized_host).zero? + end + + def supported_context? + super(@json) + end + + def expected_type? + %w(Note Article).include? @json['type'] + end +end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..92e2dbb30fa9c035bcac0144258c67b20a470863 --- /dev/null +++ b/app/services/activitypub/process_account_service.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +class ActivityPub::ProcessAccountService < BaseService + include JsonLdHelper + + # Should be called with confirmed valid JSON + # and WebFinger-resolved username and domain + def call(username, domain, json) + @json = json + @uri = @json['id'] + @username = username + @domain = domain + @account = Account.find_by(uri: @uri) + + create_account if @account.nil? + update_account + + @account + rescue Oj::ParseError + nil + end + + private + + def create_account + @account = Account.new + @account.username = @username + @account.domain = @domain + @account.uri = @uri + @account.suspended = true if auto_suspend? + @account.silenced = true if auto_silence? + @account.private_key = nil + @account.save! + end + + def update_account + @account.last_webfingered_at = Time.now.utc + @account.protocol = :activitypub + @account.inbox_url = @json['inbox'] || '' + @account.outbox_url = @json['outbox'] || '' + @account.shared_inbox_url = @json['sharedInbox'] || '' + @account.followers_url = @json['followers'] || '' + @account.url = @json['url'] || @uri + @account.display_name = @json['name'] || '' + @account.note = @json['summary'] || '' + @account.avatar_remote_url = image_url('icon') + @account.header_remote_url = image_url('image') + @account.public_key = public_key || '' + @account.save! + end + + def image_url(key) + value = first_of_value(@json[key]) + + return if value.nil? + return @json[key]['url'] if @json[key].is_a?(Hash) + + image = fetch_resource(value) + image['url'] if image + end + + def public_key + value = first_of_value(@json['publicKey']) + + return if value.nil? + return value['publicKeyPem'] if value.is_a?(Hash) + + key = fetch_resource(value) + key['publicKeyPem'] if key + end + + def auto_suspend? + domain_block && domain_block.suspend? + end + + def auto_silence? + domain_block && domain_block.silence? + end + + def domain_block + return @domain_block if defined?(@domain_block) + @domain_block = DomainBlock.find_by(domain: @domain) + end +end diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..cd861c07530ccad71641162bdeaa28e3104466e1 --- /dev/null +++ b/app/services/activitypub/process_collection_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ActivityPub::ProcessCollectionService < BaseService + include JsonLdHelper + + def call(body, account) + @account = account + @json = Oj.load(body, mode: :strict) + + return if @account.suspended? || !supported_context? + + case @json['type'] + when 'Collection', 'CollectionPage' + process_items @json['items'] + when 'OrderedCollection', 'OrderedCollectionPage' + process_items @json['orderedItems'] + else + process_items [@json] + end + rescue Oj::ParseError + nil + end + + private + + def process_items(items) + items.reverse_each.map { |item| process_item(item) }.compact + end + + def supported_context? + super(@json) + end + + def process_item(item) + activity = ActivityPub::Activity.factory(item, @account) + activity&.perform + end +end diff --git a/app/services/resolve_remote_account_service.rb b/app/services/resolve_remote_account_service.rb index e0e2ebc8310cff113183338f6201bd164c91e2c3..220ef043cba5ad587724189c841fe2f30a7d33b5 100644 --- a/app/services/resolve_remote_account_service.rb +++ b/app/services/resolve_remote_account_service.rb @@ -2,6 +2,7 @@ class ResolveRemoteAccountService < BaseService include OStatus2::MagicKey + include JsonLdHelper DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0' @@ -12,6 +13,7 @@ class ResolveRemoteAccountService < BaseService # @return [Account] def call(uri, update_profile = true, redirected = nil) @username, @domain = uri.split('@') + @update_profile = update_profile return Account.find_local(@username) if TagManager.instance.local_domain?(@domain) @@ -42,10 +44,11 @@ class ResolveRemoteAccountService < BaseService if lock.acquired? @account = Account.find_remote(@username, @domain) - create_account if @account.nil? - update_account - - update_account_profile if update_profile + if activitypub_ready? + handle_activitypub + else + handle_ostatus + end end end @@ -58,18 +61,47 @@ class ResolveRemoteAccountService < BaseService private def links_missing? - @webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? || + !(activitypub_ready? || ostatus_ready?) + end + + def ostatus_ready? + !(@webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? || @webfinger.link('salmon').nil? || @webfinger.link('http://webfinger.net/rel/profile-page').nil? || @webfinger.link('magic-public-key').nil? || canonical_uri.nil? || - hub_url.nil? + hub_url.nil?) end def webfinger_update_due? @account.nil? || @account.last_webfingered_at.nil? || @account.last_webfingered_at <= 1.day.ago end + def activitypub_ready? + !@webfinger.link('self').nil? && + ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type) + end + + def handle_ostatus + create_account if @account.nil? + update_account + update_account_profile if update_profile? + end + + def update_profile? + @update_profile + end + + def handle_activitypub + json = fetch_resource(actor_url) + + return unless supported_context?(json) && json['type'] == 'Person' + + @account = ActivityPub::ProcessAccountService.new.call(@username, @domain, json) + rescue Oj::ParseError + nil + end + def create_account Rails.logger.debug "Creating new remote account for #{@username}@#{@domain}" @@ -81,6 +113,7 @@ class ResolveRemoteAccountService < BaseService def update_account @account.last_webfingered_at = Time.now.utc + @account.protocol = :ostatus @account.remote_url = atom_url @account.salmon_url = salmon_url @account.url = url @@ -111,6 +144,10 @@ class ResolveRemoteAccountService < BaseService @salmon_url ||= @webfinger.link('salmon').href end + def actor_url + @actor_url ||= @webfinger.link('self').href + end + def url @url ||= @webfinger.link('http://webfinger.net/rel/profile-page').href end diff --git a/app/workers/activitypub/processing_worker.rb b/app/workers/activitypub/processing_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..7656ab56a38b3ca5443c7467c5c0aea13be77cb0 --- /dev/null +++ b/app/workers/activitypub/processing_worker.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class ActivityPub::ProcessingWorker + include Sidekiq::Worker + + sidekiq_options backtrace: true + + def perform(account_id, body) + ProcessCollectionService.new.call(body, Account.find(account_id)) + end +end diff --git a/app/workers/activitypub/thread_resolve_worker.rb b/app/workers/activitypub/thread_resolve_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..4ef762d0642f954fd7d66f0cdd010ce6e682db94 --- /dev/null +++ b/app/workers/activitypub/thread_resolve_worker.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class ActivityPub::ThreadResolveWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull', retry: false + + def perform(child_status_id, parent_uri) + child_status = Status.find(child_status_id) + parent_status = ActivityPub::FetchRemoteStatusService.new.call(parent_uri) + + return if parent_status.nil? + + child_status.thread = parent_status + child_status.save! + end +end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 44e54c9f36925238229767144782b959ab3fff63..bf0cb52a30a2ac2832408f712a7e51dfa015885c 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -17,4 +17,5 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym 'ActivityPub' inflect.acronym 'PubSubHubbub' inflect.acronym 'ActivityStreams' + inflect.acronym 'JsonLd' end diff --git a/config/routes.rb b/config/routes.rb index 400fa88c53d077a7af6de32e08ded2fb3e65c8cd..a1b20671643c36d14a57eeaa1c6992af33f0df93 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -51,6 +51,7 @@ Rails.application.routes.draw do resource :follow, only: [:create], controller: :account_follow resource :unfollow, only: [:create], controller: :account_unfollow resource :outbox, only: [:show], module: :activitypub + resource :inbox, only: [:create], module: :activitypub end get '/@:username', to: 'accounts#show', as: :short_account diff --git a/spec/controllers/activitypub/inboxes_controller_spec.rb b/spec/controllers/activitypub/inboxes_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5c12fea7df8457451c4742d62b6231c7f1c28ad4 --- /dev/null +++ b/spec/controllers/activitypub/inboxes_controller_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::InboxesController, type: :controller do + describe 'POST #create' do + pending + end +end diff --git a/spec/controllers/activitypub/outboxes_controller_spec.rb b/spec/controllers/activitypub/outboxes_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f98e4a8c38c536068abe861fda2744cdfabb3e4e --- /dev/null +++ b/spec/controllers/activitypub/outboxes_controller_spec.rb @@ -0,0 +1,19 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::OutboxesController, type: :controller do + let!(:account) { Fabricate(:account) } + + before do + Fabricate(:status, account: account) + end + + describe 'GET #show' do + before do + get :show, params: { account_username: account.username } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + end +end diff --git a/spec/helpers/jsonld_helper_spec.rb b/spec/helpers/jsonld_helper_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7d3912e6c4327f4c8389bd2f92dee5d38ae7effb --- /dev/null +++ b/spec/helpers/jsonld_helper_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe JsonLdHelper do + describe '#equals_or_includes?' do + it 'returns true when value equals' do + expect(helper.equals_or_includes?('foo', 'foo')).to be true + end + + it 'returns false when value does not equal' do + expect(helper.equals_or_includes?('foo', 'bar')).to be false + end + + it 'returns true when value is included' do + expect(helper.equals_or_includes?(%w(foo baz), 'foo')).to be true + end + + it 'returns false when value is not included' do + expect(helper.equals_or_includes?(%w(foo baz), 'bar')).to be false + end + end + + describe '#first_of_value' do + pending + end + + describe '#supported_context?' do + pending + end + + describe '#fetch_resource' do + pending + end +end diff --git a/spec/lib/activitypub/activity/announce_spec.rb b/spec/lib/activitypub/activity/announce_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..54dd52a60490a1d72ce207a9e544f5212c67c3ee --- /dev/null +++ b/spec/lib/activitypub/activity/announce_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Announce do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + let(:status) { Fabricate(:status, account: recipient) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Announce', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'creates a reblog by sender of status' do + expect(sender.reblogged?(status)).to be true + end + end +end diff --git a/spec/lib/activitypub/activity/block_spec.rb b/spec/lib/activitypub/activity/block_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..23c8cc31cf799e49d402009fa3e84a0694d9dc20 --- /dev/null +++ b/spec/lib/activitypub/activity/block_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Block do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Block', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(recipient), + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'creates a block from sender to recipient' do + expect(sender.blocking?(recipient)).to be true + end + end +end diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..fcb044ebcbfd9d19de9bffc6612e62b3b7cf3091 --- /dev/null +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -0,0 +1,221 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Create do + let(:sender) { Fabricate(:account, followers_url: 'http://example.com/followers') } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Create', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: object_json, + }.with_indifferent_access + end + + subject { described_class.new(json, sender) } + + before do + stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt')) + end + + describe '#perform' do + before do + subject.perform + end + + context 'standalone' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.text).to eq 'Lorem ipsum' + end + + it 'missing to/cc defaults to direct privacy' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'direct' + end + end + + context 'public' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + to: 'https://www.w3.org/ns/activitystreams#Public', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'public' + end + end + + context 'unlisted' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + cc: 'https://www.w3.org/ns/activitystreams#Public', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'unlisted' + end + end + + context 'private' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + to: 'http://example.com/followers', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'private' + end + end + + context 'direct' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + to: ActivityPub::TagManager.instance.uri_for(recipient), + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'direct' + end + end + + context 'as a reply' do + let(:original_status) { Fabricate(:status) } + + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + inReplyTo: ActivityPub::TagManager.instance.uri_for(original_status), + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.thread).to eq original_status + expect(status.reply?).to be true + expect(status.in_reply_to_account).to eq original_status.account + expect(status.conversation).to eq original_status.conversation + end + end + + context 'with mentions' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + tag: [ + { + type: 'Mention', + href: ActivityPub::TagManager.instance.uri_for(recipient), + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.mentions.map(&:account)).to include(recipient) + end + end + + context 'with media attachments' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + attachment: [ + { + type: 'Document', + mime_type: 'image/png', + url: 'http://example.com/attachment.png', + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.media_attachments.map(&:remote_url)).to include('http://example.com/attachment.png') + end + end + + context 'with hashtags' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + tag: [ + { + type: 'Hashtag', + href: 'http://example.com/blah', + name: '#test', + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.tags.map(&:name)).to include('test') + end + end + end +end diff --git a/spec/lib/activitypub/activity/delete_spec.rb b/spec/lib/activitypub/activity/delete_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..398669b485f55485a07291d4ca4b0b582d700a4e --- /dev/null +++ b/spec/lib/activitypub/activity/delete_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Delete do + let(:sender) { Fabricate(:account) } + let(:status) { Fabricate(:status, account: sender, uri: 'foobar') } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Delete', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'deletes sender\'s status' do + expect(Status.find_by(id: status.id)).to be_nil + end + end +end diff --git a/spec/lib/activitypub/activity/follow_spec.rb b/spec/lib/activitypub/activity/follow_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7c0e447f35a235279fb0d028b8565f5ecdee5491 --- /dev/null +++ b/spec/lib/activitypub/activity/follow_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Follow do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Follow', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(recipient), + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'creates a follow from sender to recipient' do + expect(sender.following?(recipient)).to be true + end + end +end diff --git a/spec/lib/activitypub/activity/like_spec.rb b/spec/lib/activitypub/activity/like_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b69615a9d1bafbcbfc3bdcab668d66e317474e29 --- /dev/null +++ b/spec/lib/activitypub/activity/like_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Like do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + let(:status) { Fabricate(:status, account: recipient) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Like', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'creates a favourite from sender to status' do + expect(sender.favourited?(status)).to be true + end + end +end diff --git a/spec/lib/activitypub/activity/undo_spec.rb b/spec/lib/activitypub/activity/undo_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4629a033f2829be462037e61d2df6b69c24e6374 --- /dev/null +++ b/spec/lib/activitypub/activity/undo_spec.rb @@ -0,0 +1,107 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Undo do + let(:sender) { Fabricate(:account) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Undo', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: object_json, + }.with_indifferent_access + end + + subject { described_class.new(json, sender) } + + describe '#perform' do + context 'with Announce' do + let(:status) { Fabricate(:status) } + + let(:object_json) do + { + id: 'bar', + type: 'Announce', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + } + end + + before do + Fabricate(:status, reblog: status, account: sender, uri: 'bar') + end + + it 'deletes the reblog' do + subject.perform + expect(sender.reblogged?(status)).to be false + end + end + + context 'with Block' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: 'bar', + type: 'Block', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(recipient), + } + end + + before do + sender.block!(recipient) + end + + it 'deletes block from sender to recipient' do + subject.perform + expect(sender.blocking?(recipient)).to be false + end + end + + context 'with Follow' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: 'bar', + type: 'Follow', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(recipient), + } + end + + before do + sender.follow!(recipient) + end + + it 'deletes follow from sender to recipient' do + subject.perform + expect(sender.following?(recipient)).to be false + end + end + + context 'with Like' do + let(:status) { Fabricate(:status) } + + let(:object_json) do + { + id: 'bar', + type: 'Like', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + } + end + + before do + Fabricate(:favourite, account: sender, status: status) + end + + it 'deletes favourite from sender to status' do + subject.perform + expect(sender.favourited?(status)).to be false + end + end + end +end diff --git a/spec/lib/activitypub/activity/update_spec.rb b/spec/lib/activitypub/activity/update_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0bd6d00d9cafb51d673cdfd9e5b30460867e0cfa --- /dev/null +++ b/spec/lib/activitypub/activity/update_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Update do + let!(:sender) { Fabricate(:account) } + + before do + sender.update!(uri: ActivityPub::TagManager.instance.uri_for(sender)) + end + + let(:modified_sender) do + sender.dup.tap do |modified_sender| + modified_sender.display_name = 'Totally modified now' + end + end + + let(:actor_json) do + ActiveModelSerializers::SerializableResource.new(modified_sender, serializer: ActivityPub::ActorSerializer, key_transform: :camel_lower).as_json + end + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Update', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: actor_json, + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'updates profile' do + expect(sender.reload.display_name).to eq 'Totally modified now' + end + end +end diff --git a/spec/lib/activitypub/tag_manager_spec.rb b/spec/lib/activitypub/tag_manager_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8f7662e24a39c14b4f305688e0d12d2229b038e1 --- /dev/null +++ b/spec/lib/activitypub/tag_manager_spec.rb @@ -0,0 +1,99 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::TagManager do + include RoutingHelper + + subject { described_class.instance } + + describe '#url_for' do + it 'returns a string' do + account = Fabricate(:account) + expect(subject.url_for(account)).to be_a String + end + end + + describe '#uri_for' do + it 'returns a string' do + account = Fabricate(:account) + expect(subject.uri_for(account)).to be_a String + end + end + + describe '#to' do + it 'returns public collection for public status' do + status = Fabricate(:status, visibility: :public) + expect(subject.to(status)).to eq ['https://www.w3.org/ns/activitystreams#Public'] + end + + it 'returns followers collection for unlisted status' do + status = Fabricate(:status, visibility: :unlisted) + expect(subject.to(status)).to eq [account_followers_url(status.account)] + end + + it 'returns followers collection for private status' do + status = Fabricate(:status, visibility: :private) + expect(subject.to(status)).to eq [account_followers_url(status.account)] + end + + it 'returns URIs of mentions for direct status' do + status = Fabricate(:status, visibility: :direct) + mentioned = Fabricate(:account) + status.mentions.create(account: mentioned) + expect(subject.to(status)).to eq [subject.uri_for(mentioned)] + end + end + + describe '#cc' do + it 'returns followers collection for public status' do + status = Fabricate(:status, visibility: :public) + expect(subject.cc(status)).to eq [account_followers_url(status.account)] + end + + it 'returns public collection for unlisted status' do + status = Fabricate(:status, visibility: :unlisted) + expect(subject.cc(status)).to eq ['https://www.w3.org/ns/activitystreams#Public'] + end + + it 'returns empty array for private status' do + status = Fabricate(:status, visibility: :private) + expect(subject.cc(status)).to eq [] + end + + it 'returns empty array for direct status' do + status = Fabricate(:status, visibility: :direct) + expect(subject.cc(status)).to eq [] + end + + it 'returns URIs of mentions for non-direct status' do + status = Fabricate(:status, visibility: :public) + mentioned = Fabricate(:account) + status.mentions.create(account: mentioned) + expect(subject.cc(status)).to include(subject.uri_for(mentioned)) + end + end + + describe '#local_uri?' do + it 'returns false for non-local URI' do + expect(subject.local_uri?('http://example.com/123')).to be false + end + + it 'returns true for local URIs' do + account = Fabricate(:account) + expect(subject.local_uri?(subject.uri_for(account))).to be true + end + end + + describe '#uri_to_local_id' do + it 'returns the local ID' do + account = Fabricate(:account) + expect(subject.uri_to_local_id(subject.uri_for(account), :username)).to eq account.username + end + end + + describe '#uri_to_resource' do + it 'returns the local resource' do + account = Fabricate(:account) + expect(subject.uri_to_resource(subject.uri_for(account), Account)).to eq account + end + end +end diff --git a/spec/services/activitypub/fetch_remote_account_service_spec.rb b/spec/services/activitypub/fetch_remote_account_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..786d7f7f2b0da3899747d6c7773ec0fd6f6a4205 --- /dev/null +++ b/spec/services/activitypub/fetch_remote_account_service_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::FetchRemoteAccountService do + subject { ActivityPub::FetchRemoteAccountService.new } + + let!(:actor) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/alice', + type: 'Person', + preferredUsername: 'alice', + name: 'Alice', + summary: 'Foo bar', + } + end + + describe '#call' do + let(:account) { subject.call('https://example.com/alice') } + + shared_examples 'sets profile data' do + it 'returns an account' do + expect(account).to be_an Account + end + + it 'sets display name' do + expect(account.display_name).to eq 'Alice' + end + + it 'sets note' do + expect(account.note).to eq 'Foo bar' + end + + it 'sets URL' do + expect(account.url).to eq 'https://example.com/alice' + end + end + + context 'when URI and WebFinger share the same host' do + let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } + + before do + stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) + stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) + end + + it 'fetches resource' do + account + expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once + end + + it 'looks up webfinger' do + account + expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once + end + + it 'sets username and domain from webfinger' do + expect(account.username).to eq 'alice' + expect(account.domain).to eq 'example.com' + end + + include_examples 'sets profile data' + end + + context 'when WebFinger presents different domain than URI' do + let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } + + before do + stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) + stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) + stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) + end + + it 'fetches resource' do + account + expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once + end + + it 'looks up webfinger' do + account + expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once + end + + it 'looks up "redirected" webfinger' do + account + expect(a_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af')).to have_been_made.once + end + + it 'sets username and domain from final webfinger' do + expect(account.username).to eq 'alice' + expect(account.domain).to eq 'iscool.af' + end + + include_examples 'sets profile data' + end + end +end diff --git a/spec/services/activitypub/fetch_remote_status_service_spec.rb b/spec/services/activitypub/fetch_remote_status_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..47a33b6cb6ddcba4860f74c3b5bd741ad6a115d1 --- /dev/null +++ b/spec/services/activitypub/fetch_remote_status_service_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::FetchRemoteStatusService do + pending +end diff --git a/spec/services/activitypub/process_account_service_spec.rb b/spec/services/activitypub/process_account_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..84a74c231f06f3007f452fd2ffd008177ef5f835 --- /dev/null +++ b/spec/services/activitypub/process_account_service_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::ProcessAccountService do + pending +end diff --git a/spec/services/activitypub/process_collection_service_spec.rb b/spec/services/activitypub/process_collection_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6486483f65b6c04ab041622f64bb8284a2fef0a3 --- /dev/null +++ b/spec/services/activitypub/process_collection_service_spec.rb @@ -0,0 +1,9 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::ProcessCollectionService do + subject { ActivityPub::ProcessCollectionService.new } + + describe '#call' do + pending + end +end