* 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 testspull/4/head
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -0,0 +1,7 @@ | |||
require 'rails_helper' | |||
RSpec.describe ActivityPub::InboxesController, type: :controller do | |||
describe 'POST #create' do | |||
pending | |||
end | |||
end |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |