Add ActivityPub inbox (#4216)
* Add ActivityPub inbox * Handle ActivityPub deletes * Handle ActivityPub creates * Handle ActivityPub announces * Stubs for handling all activities that need to be handled * Add ActivityPub actor resolving * Handle conversation URI passing in ActivityPub * Handle content language in ActivityPub * Send accept header when fetching actor, handle JSON parse errors * Test for ActivityPub::FetchRemoteAccountService * Handle public key and icon/image when embedded/as array/as resolvable URI * Implement ActivityPub::FetchRemoteStatusService * Add stubs for more interactions * Undo activities implemented * Handle out of order activities * Hook up ActivityPub to ResolveRemoteAccountService, handle Update Account activities * Add fragment IDs to all transient activity serializers * Add tests and fixes * Add stubs for missing tests * Add more tests * Add more testsmaster
parent
dcbc1af38a
commit
dd7ef0dc41
@ -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', |
||||