mirror of
https://github.com/kikobar/mastodon.git
synced 2024-11-16 20:22:18 +00:00
Add ActivityPub inbox (#4216)
* Add ActivityPub inbox * Handle ActivityPub deletes * Handle ActivityPub creates * Handle ActivityPub announces * Stubs for handling all activities that need to be handled * Add ActivityPub actor resolving * Handle conversation URI passing in ActivityPub * Handle content language in ActivityPub * Send accept header when fetching actor, handle JSON parse errors * Test for ActivityPub::FetchRemoteAccountService * Handle public key and icon/image when embedded/as array/as resolvable URI * Implement ActivityPub::FetchRemoteStatusService * Add stubs for more interactions * Undo activities implemented * Handle out of order activities * Hook up ActivityPub to ResolveRemoteAccountService, handle Update Account activities * Add fragment IDs to all transient activity serializers * Add tests and fixes * Add stubs for missing tests * Add more tests * Add more tests
This commit is contained in:
parent
dcbc1af38a
commit
dd7ef0dc41
30
app/controllers/activitypub/inboxes_controller.rb
Normal file
30
app/controllers/activitypub/inboxes_controller.rb
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::InboxesController < Api::BaseController
|
||||||
|
include SignatureVerification
|
||||||
|
|
||||||
|
before_action :set_account
|
||||||
|
|
||||||
|
def create
|
||||||
|
if signed_request_account
|
||||||
|
process_payload
|
||||||
|
head 201
|
||||||
|
else
|
||||||
|
head 202
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_account
|
||||||
|
@account = Account.find_local!(params[:account_username])
|
||||||
|
end
|
||||||
|
|
||||||
|
def body
|
||||||
|
@body ||= request.body.read
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_payload
|
||||||
|
ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body.force_encoding('UTF-8'))
|
||||||
|
end
|
||||||
|
end
|
31
app/helpers/jsonld_helper.rb
Normal file
31
app/helpers/jsonld_helper.rb
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module JsonLdHelper
|
||||||
|
def equals_or_includes?(haystack, needle)
|
||||||
|
haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle
|
||||||
|
end
|
||||||
|
|
||||||
|
def first_of_value(value)
|
||||||
|
value.is_a?(Array) ? value.first : value
|
||||||
|
end
|
||||||
|
|
||||||
|
def supported_context?(json)
|
||||||
|
equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_resource(uri)
|
||||||
|
response = build_request(uri).perform
|
||||||
|
return if response.code != 200
|
||||||
|
Oj.load(response.to_s, mode: :strict)
|
||||||
|
rescue Oj::ParseError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def build_request(uri)
|
||||||
|
request = Request.new(:get, uri)
|
||||||
|
request.add_headers('Accept' => 'application/activity+json')
|
||||||
|
request
|
||||||
|
end
|
||||||
|
end
|
109
app/lib/activitypub/activity.rb
Normal file
109
app/lib/activitypub/activity.rb
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::Activity
|
||||||
|
include JsonLdHelper
|
||||||
|
|
||||||
|
def initialize(json, account)
|
||||||
|
@json = json
|
||||||
|
@account = account
|
||||||
|
@object = @json['object']
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def factory(json, account)
|
||||||
|
@json = json
|
||||||
|
klass&.new(json, account)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def klass
|
||||||
|
case @json['type']
|
||||||
|
when 'Create'
|
||||||
|
ActivityPub::Activity::Create
|
||||||
|
when 'Announce'
|
||||||
|
ActivityPub::Activity::Announce
|
||||||
|
when 'Delete'
|
||||||
|
ActivityPub::Activity::Delete
|
||||||
|
when 'Follow'
|
||||||
|
ActivityPub::Activity::Follow
|
||||||
|
when 'Like'
|
||||||
|
ActivityPub::Activity::Like
|
||||||
|
when 'Block'
|
||||||
|
ActivityPub::Activity::Block
|
||||||
|
when 'Update'
|
||||||
|
ActivityPub::Activity::Update
|
||||||
|
when 'Undo'
|
||||||
|
ActivityPub::Activity::Undo
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
def status_from_uri(uri)
|
||||||
|
ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_from_uri(uri)
|
||||||
|
ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def object_uri
|
||||||
|
@object_uri ||= @object.is_a?(String) ? @object : @object['id']
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis
|
||||||
|
Redis.current
|
||||||
|
end
|
||||||
|
|
||||||
|
def distribute(status)
|
||||||
|
notify_about_reblog(status) if reblog_of_local_account?(status)
|
||||||
|
notify_about_mentions(status)
|
||||||
|
crawl_links(status)
|
||||||
|
distribute_to_followers(status)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reblog_of_local_account?(status)
|
||||||
|
status.reblog? && status.reblog.account.local?
|
||||||
|
end
|
||||||
|
|
||||||
|
def notify_about_reblog(status)
|
||||||
|
NotifyService.new.call(status.reblog.account, status)
|
||||||
|
end
|
||||||
|
|
||||||
|
def notify_about_mentions(status)
|
||||||
|
status.mentions.includes(:account).each do |mention|
|
||||||
|
next unless mention.account.local? && audience_includes?(mention.account)
|
||||||
|
NotifyService.new.call(mention.account, mention)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def crawl_links(status)
|
||||||
|
return if status.spoiler_text?
|
||||||
|
LinkCrawlWorker.perform_async(status.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def distribute_to_followers(status)
|
||||||
|
DistributionWorker.perform_async(status.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_arrived_first?(uri)
|
||||||
|
key = "delete_upon_arrival:#{@account.id}:#{uri}"
|
||||||
|
|
||||||
|
if redis.exists(key)
|
||||||
|
redis.del(key)
|
||||||
|
true
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_later!(uri)
|
||||||
|
redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri)
|
||||||
|
end
|
||||||
|
end
|
14
app/lib/activitypub/activity/announce.rb
Normal file
14
app/lib/activitypub/activity/announce.rb
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::Activity::Announce < ActivityPub::Activity
|
||||||
|
def perform
|
||||||
|
original_status = status_from_uri(object_uri)
|
||||||
|
original_status = ActivityPub::FetchRemoteStatusService.new.call(object_uri) if original_status.nil?
|
||||||
|
|
||||||
|
return if original_status.nil? || delete_arrived_first?(@json['id'])
|
||||||
|
|
||||||
|
status = Status.create!(account: @account, reblog: original_status, uri: @json['id'])
|
||||||
|
distribute(status)
|
||||||
|
status
|
||||||
|
end
|
||||||
|
end
|
12
app/lib/activitypub/activity/block.rb
Normal file
12
app/lib/activitypub/activity/block.rb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::Activity::Block < ActivityPub::Activity
|
||||||
|
def perform
|
||||||
|
target_account = account_from_uri(object_uri)
|
||||||
|
|
||||||
|
return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id'])
|
||||||
|
|
||||||
|
UnfollowService.new.call(target_account, @account) if target_account.following?(@account)
|
||||||
|
@account.block!(target_account)
|
||||||
|
end
|
||||||
|
end
|
148
app/lib/activitypub/activity/create.rb
Normal file
148
app/lib/activitypub/activity/create.rb
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||||
|
def perform
|
||||||
|
return if delete_arrived_first?(object_uri) || unsupported_object_type?
|
||||||
|
|
||||||
|
status = Status.find_by(uri: object_uri)
|
||||||
|
|
||||||
|
return status unless status.nil?
|
||||||
|
|
||||||
|
ApplicationRecord.transaction do
|
||||||
|
status = Status.create!(status_params)
|
||||||
|
|
||||||
|
process_tags(status)
|
||||||
|
process_attachments(status)
|
||||||
|
end
|
||||||
|
|
||||||
|
resolve_thread(status)
|
||||||
|
distribute(status)
|
||||||
|
|
||||||
|
status
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def status_params
|
||||||
|
{
|
||||||
|
uri: @object['id'],
|
||||||
|
url: @object['url'],
|
||||||
|
account: @account,
|
||||||
|
text: text_from_content || '',
|
||||||
|
language: language_from_content,
|
||||||
|
spoiler_text: @object['summary'] || '',
|
||||||
|
created_at: @object['published'] || Time.now.utc,
|
||||||
|
reply: @object['inReplyTo'].present?,
|
||||||
|
sensitive: @object['sensitive'] || false,
|
||||||
|
visibility: visibility_from_audience,
|
||||||
|
thread: replied_to_status,
|
||||||
|
conversation: conversation_from_uri(@object['_:conversation']),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_tags(status)
|
||||||
|
return unless @object['tag'].is_a?(Array)
|
||||||
|
|
||||||
|
@object['tag'].each do |tag|
|
||||||
|
case tag['type']
|
||||||
|
when 'Hashtag'
|
||||||
|
process_hashtag tag, status
|
||||||
|
when 'Mention'
|
||||||
|
process_mention tag, status
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_hashtag(tag, status)
|
||||||
|
hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
|
||||||
|
hashtag = Tag.where(name: hashtag).first_or_initialize(name: hashtag)
|
||||||
|
|
||||||
|
status.tags << hashtag
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_mention(tag, status)
|
||||||
|
account = account_from_uri(tag['href'])
|
||||||
|
account = ActivityPub::FetchRemoteAccountService.new.call(tag['href']) if account.nil?
|
||||||
|
return if account.nil?
|
||||||
|
account.mentions.create(status: status)
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_attachments(status)
|
||||||
|
return unless @object['attachment'].is_a?(Array)
|
||||||
|
|
||||||
|
@object['attachment'].each do |attachment|
|
||||||
|
next if unsupported_media_type?(attachment['mediaType'])
|
||||||
|
|
||||||
|
href = Addressable::URI.parse(attachment['url']).normalize.to_s
|
||||||
|
media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href)
|
||||||
|
|
||||||
|
next if skip_download?
|
||||||
|
|
||||||
|
media_attachment.file_remote_url = href
|
||||||
|
media_attachment.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_thread(status)
|
||||||
|
return unless status.reply? && status.thread.nil?
|
||||||
|
ActivityPub::ThreadResolveWorker.perform_async(status.id, @object['inReplyTo'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def conversation_from_uri(uri)
|
||||||
|
return nil if uri.nil?
|
||||||
|
return Conversation.find_by(id: TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if TagManager.instance.local_id?(uri)
|
||||||
|
Conversation.find_by(uri: uri) || Conversation.create!(uri: uri)
|
||||||
|
end
|
||||||
|
|
||||||
|
def visibility_from_audience
|
||||||
|
if equals_or_includes?(@object['to'], ActivityPub::TagManager::COLLECTIONS[:public])
|
||||||
|
:public
|
||||||
|
elsif equals_or_includes?(@object['cc'], ActivityPub::TagManager::COLLECTIONS[:public])
|
||||||
|
:unlisted
|
||||||
|
elsif equals_or_includes?(@object['to'], @account.followers_url)
|
||||||
|
:private
|
||||||
|
else
|
||||||
|
:direct
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def audience_includes?(account)
|
||||||
|
uri = ActivityPub::TagManager.instance.uri_for(account)
|
||||||
|
equals_or_includes?(@object['to'], uri) || equals_or_includes?(@object['cc'], uri)
|
||||||
|
end
|
||||||
|
|
||||||
|
def replied_to_status
|
||||||
|
return if @object['inReplyTo'].blank?
|
||||||
|
@replied_to_status ||= status_from_uri(@object['inReplyTo'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def text_from_content
|
||||||
|
if @object['content'].present?
|
||||||
|
@object['content']
|
||||||
|
elsif language_map?
|
||||||
|
@object['contentMap'].values.first
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def language_from_content
|
||||||
|
return nil unless language_map?
|
||||||
|
@object['contentMap'].keys.first
|
||||||
|
end
|
||||||
|
|
||||||
|
def language_map?
|
||||||
|
@object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def unsupported_object_type?
|
||||||
|
@object.is_a?(String) || !%w(Article Note).include?(@object['type'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def unsupported_media_type?(mime_type)
|
||||||
|
mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
|
||||||
|
end
|
||||||
|
|
||||||
|
def skip_download?
|
||||||
|
return @skip_download if defined?(@skip_download)
|
||||||
|
@skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
|
||||||
|
end
|
||||||
|
end
|
13
app/lib/activitypub/activity/delete.rb
Normal file
13
app/lib/activitypub/activity/delete.rb
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::Activity::Delete < ActivityPub::Activity
|
||||||
|
def perform
|
||||||
|
status = Status.find_by(uri: object_uri, account: @account)
|
||||||
|
|
||||||
|
if status.nil?
|
||||||
|
delete_later!(object_uri)
|
||||||
|
else
|
||||||
|
RemoveStatusService.new.call(status)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
12
app/lib/activitypub/activity/follow.rb
Normal file
12
app/lib/activitypub/activity/follow.rb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::Activity::Follow < ActivityPub::Activity
|
||||||
|
def perform
|
||||||
|
target_account = account_from_uri(object_uri)
|
||||||
|
|
||||||
|
return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id'])
|
||||||
|
|
||||||
|
follow = @account.follow!(target_account)
|
||||||
|
NotifyService.new.call(target_account, follow)
|
||||||
|
end
|
||||||
|
end
|
12
app/lib/activitypub/activity/like.rb
Normal file
12
app/lib/activitypub/activity/like.rb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::Activity::Like < ActivityPub::Activity
|
||||||
|
def perform
|
||||||
|
original_status = status_from_uri(object_uri)
|
||||||
|
|
||||||
|
return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id'])
|
||||||
|
|
||||||
|
favourite = original_status.favourites.where(account: @account).first_or_create!(account: @account)
|
||||||
|
NotifyService.new.call(original_status.account, favourite)
|
||||||
|
end
|
||||||
|
end
|
69
app/lib/activitypub/activity/undo.rb
Normal file
69
app/lib/activitypub/activity/undo.rb
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::Activity::Undo < ActivityPub::Activity
|
||||||
|
def perform
|
||||||
|
case @object['type']
|
||||||
|
when 'Announce'
|
||||||
|
undo_announce
|
||||||
|
when 'Follow'
|
||||||
|
undo_follow
|
||||||
|
when 'Like'
|
||||||
|
undo_like
|
||||||
|
when 'Block'
|
||||||
|
undo_block
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def undo_announce
|
||||||
|
status = Status.find_by(uri: object_uri, account: @account)
|
||||||
|
|
||||||
|
if status.nil?
|
||||||
|
delete_later!(object_uri)
|
||||||
|
else
|
||||||
|
RemoveStatusService.new.call(status)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def undo_follow
|
||||||
|
target_account = account_from_uri(target_uri)
|
||||||
|
|
||||||
|
return if target_account.nil? || !target_account.local?
|
||||||
|
|
||||||
|
if @account.following?(target_account)
|
||||||
|
@account.unfollow!(target_account)
|
||||||
|
else
|
||||||
|
delete_later!(object_uri)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def undo_like
|
||||||
|
status = status_from_uri(target_uri)
|
||||||
|
|
||||||
|
return if status.nil? || !status.account.local?
|
||||||
|
|
||||||
|
if @account.favourited?(status)
|
||||||
|
favourite = status.favourites.where(account: @account).first
|
||||||
|
favourite&.destroy
|
||||||
|
else
|
||||||
|
delete_later!(object_uri)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def undo_block
|
||||||
|
target_account = account_from_uri(target_uri)
|
||||||
|
|
||||||
|
return if target_account.nil? || !target_account.local?
|
||||||
|
|
||||||
|
if @account.blocking?(target_account)
|
||||||
|
UnblockService.new.call(@account, target_account)
|
||||||
|
else
|
||||||
|
delete_later!(object_uri)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def target_uri
|
||||||
|
@target_uri ||= @object['object'].is_a?(String) ? @object['object'] : @object['object']['id']
|
||||||
|
end
|
||||||
|
end
|
17
app/lib/activitypub/activity/update.rb
Normal file
17
app/lib/activitypub/activity/update.rb
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::Activity::Update < ActivityPub::Activity
|
||||||
|
def perform
|
||||||
|
case @object['type']
|
||||||
|
when 'Person'
|
||||||
|
update_account
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def update_account
|
||||||
|
return if @account.uri != object_uri
|
||||||
|
ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object)
|
||||||
|
end
|
||||||
|
end
|
|
@ -7,7 +7,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
||||||
|
|
||||||
def serializable_hash(options = nil)
|
def serializable_hash(options = nil)
|
||||||
options = serialization_options(options)
|
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)
|
self.class.transform_key_casing!(serialized_hash, instance_options)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,6 +6,8 @@ class ActivityPub::TagManager
|
||||||
include Singleton
|
include Singleton
|
||||||
include RoutingHelper
|
include RoutingHelper
|
||||||
|
|
||||||
|
CONTEXT = 'https://www.w3.org/ns/activitystreams'
|
||||||
|
|
||||||
COLLECTIONS = {
|
COLLECTIONS = {
|
||||||
public: 'https://www.w3.org/ns/activitystreams#Public',
|
public: 'https://www.w3.org/ns/activitystreams#Public',
|
||||||
}.freeze
|
}.freeze
|
||||||
|
@ -66,4 +68,27 @@ class ActivityPub::TagManager
|
||||||
|
|
||||||
cc
|
cc
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -10,6 +10,8 @@ module Remotable
|
||||||
alt_method_name = "reset_#{attachment_name}!".to_sym
|
alt_method_name = "reset_#{attachment_name}!".to_sym
|
||||||
|
|
||||||
define_method method_name do |url|
|
define_method method_name do |url|
|
||||||
|
return if url.blank?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
parsed_url = Addressable::URI.parse(url).normalize
|
parsed_url = Addressable::URI.parse(url).normalize
|
||||||
rescue Addressable::URI::InvalidURIError
|
rescue Addressable::URI::InvalidURIError
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ActivityPub::AcceptFollowSerializer < ActiveModel::Serializer
|
class ActivityPub::AcceptFollowSerializer < ActiveModel::Serializer
|
||||||
attributes :type, :actor
|
attributes :id, :type, :actor
|
||||||
|
|
||||||
has_one :object, serializer: ActivityPub::FollowSerializer
|
has_one :object, serializer: ActivityPub::FollowSerializer
|
||||||
|
|
||||||
|
def id
|
||||||
|
[ActivityPub::TagManager.instance.uri_for(object.target_account), '#accepts/follows/', object.id].join
|
||||||
|
end
|
||||||
|
|
||||||
def type
|
def type
|
||||||
'Accept'
|
'Accept'
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,10 +5,27 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
|
||||||
|
|
||||||
attributes :id, :type, :following, :followers,
|
attributes :id, :type, :following, :followers,
|
||||||
:inbox, :outbox, :preferred_username,
|
:inbox, :outbox, :preferred_username,
|
||||||
:name, :summary, :icon, :image
|
:name, :summary, :url
|
||||||
|
|
||||||
has_one :public_key, serializer: ActivityPub::PublicKeySerializer
|
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
|
def id
|
||||||
account_url(object)
|
account_url(object)
|
||||||
end
|
end
|
||||||
|
@ -26,7 +43,7 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def inbox
|
def inbox
|
||||||
nil
|
account_inbox_url(object)
|
||||||
end
|
end
|
||||||
|
|
||||||
def outbox
|
def outbox
|
||||||
|
@ -46,14 +63,26 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def icon
|
def icon
|
||||||
full_asset_url(object.avatar.url(:original))
|
object.avatar
|
||||||
end
|
end
|
||||||
|
|
||||||
def image
|
def image
|
||||||
full_asset_url(object.header.url(:original))
|
object.header
|
||||||
end
|
end
|
||||||
|
|
||||||
def public_key
|
def public_key
|
||||||
object
|
object
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def url
|
||||||
|
short_account_url(object)
|
||||||
|
end
|
||||||
|
|
||||||
|
def avatar_exists?
|
||||||
|
object.avatar.exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
def header_exists?
|
||||||
|
object.header.exists?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ActivityPub::BlockSerializer < ActiveModel::Serializer
|
class ActivityPub::BlockSerializer < ActiveModel::Serializer
|
||||||
attributes :type, :actor
|
attributes :id, :type, :actor
|
||||||
attribute :virtual_object, key: :object
|
attribute :virtual_object, key: :object
|
||||||
|
|
||||||
|
def id
|
||||||
|
[ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id].join
|
||||||
|
end
|
||||||
|
|
||||||
def type
|
def type
|
||||||
'Block'
|
'Block'
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ActivityPub::DeleteSerializer < ActiveModel::Serializer
|
class ActivityPub::DeleteSerializer < ActiveModel::Serializer
|
||||||
attributes :type, :actor
|
attributes :id, :type, :actor
|
||||||
attribute :virtual_object, key: :object
|
attribute :virtual_object, key: :object
|
||||||
|
|
||||||
|
def id
|
||||||
|
[ActivityPub::TagManager.instance.uri_for(object), '#delete'].join
|
||||||
|
end
|
||||||
|
|
||||||
def type
|
def type
|
||||||
'Delete'
|
'Delete'
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ActivityPub::FollowSerializer < ActiveModel::Serializer
|
class ActivityPub::FollowSerializer < ActiveModel::Serializer
|
||||||
attributes :type, :actor
|
attributes :id, :type, :actor
|
||||||
attribute :virtual_object, key: :object
|
attribute :virtual_object, key: :object
|
||||||
|
|
||||||
|
def id
|
||||||
|
[ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id].join
|
||||||
|
end
|
||||||
|
|
||||||
def type
|
def type
|
||||||
'Follow'
|
'Follow'
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ActivityPub::LikeSerializer < ActiveModel::Serializer
|
class ActivityPub::LikeSerializer < ActiveModel::Serializer
|
||||||
attributes :type, :actor
|
attributes :id, :type, :actor
|
||||||
attribute :virtual_object, key: :object
|
attribute :virtual_object, key: :object
|
||||||
|
|
||||||
|
def id
|
||||||
|
[ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id].join
|
||||||
|
end
|
||||||
|
|
||||||
def type
|
def type
|
||||||
'Like'
|
'Like'
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ActivityPub::RejectFollowSerializer < ActiveModel::Serializer
|
class ActivityPub::RejectFollowSerializer < ActiveModel::Serializer
|
||||||
attributes :type, :actor
|
attributes :id, :type, :actor
|
||||||
|
|
||||||
has_one :object, serializer: ActivityPub::FollowSerializer
|
has_one :object, serializer: ActivityPub::FollowSerializer
|
||||||
|
|
||||||
|
def id
|
||||||
|
[ActivityPub::TagManager.instance.uri_for(object.target_account), '#rejects/follows/', object.id].join
|
||||||
|
end
|
||||||
|
|
||||||
def type
|
def type
|
||||||
'Reject'
|
'Reject'
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ActivityPub::UndoBlockSerializer < ActiveModel::Serializer
|
class ActivityPub::UndoBlockSerializer < ActiveModel::Serializer
|
||||||
attributes :type, :actor
|
attributes :id, :type, :actor
|
||||||
|
|
||||||
has_one :object, serializer: ActivityPub::BlockSerializer
|
has_one :object, serializer: ActivityPub::BlockSerializer
|
||||||
|
|
||||||
|
def id
|
||||||
|
[ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id, '/undo'].join
|
||||||
|
end
|
||||||
|
|
||||||
def type
|
def type
|
||||||
'Undo'
|
'Undo'
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ActivityPub::UndoFollowSerializer < ActiveModel::Serializer
|
class ActivityPub::UndoFollowSerializer < ActiveModel::Serializer
|
||||||
attributes :type, :actor
|
attributes :id, :type, :actor
|
||||||
|
|
||||||
has_one :object, serializer: ActivityPub::FollowSerializer
|
has_one :object, serializer: ActivityPub::FollowSerializer
|
||||||
|
|
||||||
|
def id
|
||||||
|
[ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id, '/undo'].join
|
||||||
|
end
|
||||||
|
|
||||||
def type
|
def type
|
||||||
'Undo'
|
'Undo'
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ActivityPub::UndoLikeSerializer < ActiveModel::Serializer
|
class ActivityPub::UndoLikeSerializer < ActiveModel::Serializer
|
||||||
attributes :type, :actor
|
attributes :id, :type, :actor
|
||||||
|
|
||||||
has_one :object, serializer: ActivityPub::LikeSerializer
|
has_one :object, serializer: ActivityPub::LikeSerializer
|
||||||
|
|
||||||
|
def id
|
||||||
|
[ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id, '/undo'].join
|
||||||
|
end
|
||||||
|
|
||||||
def type
|
def type
|
||||||
'Undo'
|
'Undo'
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ActivityPub::UpdateSerializer < ActiveModel::Serializer
|
class ActivityPub::UpdateSerializer < ActiveModel::Serializer
|
||||||
attributes :type, :actor
|
attributes :id, :type, :actor
|
||||||
|
|
||||||
has_one :object, serializer: ActivityPub::ActorSerializer
|
has_one :object, serializer: ActivityPub::ActorSerializer
|
||||||
|
|
||||||
|
def id
|
||||||
|
[ActivityPub::TagManager.instance.uri_for(object), '#updates/', object.updated_at.to_i].join
|
||||||
|
end
|
||||||
|
|
||||||
def type
|
def type
|
||||||
'Update'
|
'Update'
|
||||||
end
|
end
|
||||||
|
|
57
app/services/activitypub/fetch_remote_account_service.rb
Normal file
57
app/services/activitypub/fetch_remote_account_service.rb
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::FetchRemoteAccountService < BaseService
|
||||||
|
include JsonLdHelper
|
||||||
|
|
||||||
|
# Should be called when uri has already been checked for locality
|
||||||
|
# Does a WebFinger roundtrip on each call
|
||||||
|
def call(uri)
|
||||||
|
@json = fetch_resource(uri)
|
||||||
|
|
||||||
|
return unless supported_context? && expected_type?
|
||||||
|
|
||||||
|
@uri = @json['id']
|
||||||
|
@username = @json['preferredUsername']
|
||||||
|
@domain = Addressable::URI.parse(uri).normalized_host
|
||||||
|
|
||||||
|
return unless verified_webfinger?
|
||||||
|
|
||||||
|
ActivityPub::ProcessAccountService.new.call(@username, @domain, @json)
|
||||||
|
rescue Oj::ParseError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def verified_webfinger?
|
||||||
|
webfinger = Goldfinger.finger("acct:#{@username}@#{@domain}")
|
||||||
|
confirmed_username, confirmed_domain = split_acct(webfinger.subject)
|
||||||
|
|
||||||
|
return true if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
|
||||||
|
|
||||||
|
webfinger = Goldfinger.finger("acct:#{confirmed_username}@#{confirmed_domain}")
|
||||||
|
confirmed_username, confirmed_domain = split_acct(webfinger.subject)
|
||||||
|
self_reference = webfinger.link('self')
|
||||||
|
|
||||||
|
return false if self_reference&.href != @uri
|
||||||
|
|
||||||
|
@username = confirmed_username
|
||||||
|
@domain = confirmed_domain
|
||||||
|
|
||||||
|
true
|
||||||
|
rescue Goldfinger::Error
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def split_acct(acct)
|
||||||
|
acct.gsub(/\Aacct:/, '').split('@')
|
||||||
|
end
|
||||||
|
|
||||||
|
def supported_context?
|
||||||
|
super(@json)
|
||||||
|
end
|
||||||
|
|
||||||
|
def expected_type?
|
||||||
|
@json['type'] == 'Person'
|
||||||
|
end
|
||||||
|
end
|
36
app/services/activitypub/fetch_remote_status_service.rb
Normal file
36
app/services/activitypub/fetch_remote_status_service.rb
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::FetchRemoteStatusService < BaseService
|
||||||
|
include JsonLdHelper
|
||||||
|
|
||||||
|
# Should be called when uri has already been checked for locality
|
||||||
|
def call(uri)
|
||||||
|
@json = fetch_resource(uri)
|
||||||
|
|
||||||
|
return unless supported_context? && expected_type?
|
||||||
|
|
||||||
|
attributed_to = first_of_value(@json['attributedTo'])
|
||||||
|
attributed_to = attributed_to['id'] if attributed_to.is_a?(Hash)
|
||||||
|
|
||||||
|
return unless trustworthy_attribution?(uri, attributed_to)
|
||||||
|
|
||||||
|
actor = ActivityPub::TagManager.instance.uri_to_resource(attributed_to, Account)
|
||||||
|
actor = ActivityPub::FetchRemoteAccountService.new.call(attributed_to) if actor.nil?
|
||||||
|
|
||||||
|
ActivityPub::Activity::Create.new({ 'object' => @json }, actor).perform
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def trustworthy_attribution?(uri, attributed_to)
|
||||||
|
Addressable::URI.parse(uri).normalized_host.casecmp(Addressable::URI.parse(attributed_to).normalized_host).zero?
|
||||||
|
end
|
||||||
|
|
||||||
|
def supported_context?
|
||||||
|
super(@json)
|
||||||
|
end
|
||||||
|
|
||||||
|
def expected_type?
|
||||||
|
%w(Note Article).include? @json['type']
|
||||||
|
end
|
||||||
|
end
|
84
app/services/activitypub/process_account_service.rb
Normal file
84
app/services/activitypub/process_account_service.rb
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::ProcessAccountService < BaseService
|
||||||
|
include JsonLdHelper
|
||||||
|
|
||||||
|
# Should be called with confirmed valid JSON
|
||||||
|
# and WebFinger-resolved username and domain
|
||||||
|
def call(username, domain, json)
|
||||||
|
@json = json
|
||||||
|
@uri = @json['id']
|
||||||
|
@username = username
|
||||||
|
@domain = domain
|
||||||
|
@account = Account.find_by(uri: @uri)
|
||||||
|
|
||||||
|
create_account if @account.nil?
|
||||||
|
update_account
|
||||||
|
|
||||||
|
@account
|
||||||
|
rescue Oj::ParseError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def create_account
|
||||||
|
@account = Account.new
|
||||||
|
@account.username = @username
|
||||||
|
@account.domain = @domain
|
||||||
|
@account.uri = @uri
|
||||||
|
@account.suspended = true if auto_suspend?
|
||||||
|
@account.silenced = true if auto_silence?
|
||||||
|
@account.private_key = nil
|
||||||
|
@account.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_account
|
||||||
|
@account.last_webfingered_at = Time.now.utc
|
||||||
|
@account.protocol = :activitypub
|
||||||
|
@account.inbox_url = @json['inbox'] || ''
|
||||||
|
@account.outbox_url = @json['outbox'] || ''
|
||||||
|
@account.shared_inbox_url = @json['sharedInbox'] || ''
|
||||||
|
@account.followers_url = @json['followers'] || ''
|
||||||
|
@account.url = @json['url'] || @uri
|
||||||
|
@account.display_name = @json['name'] || ''
|
||||||
|
@account.note = @json['summary'] || ''
|
||||||
|
@account.avatar_remote_url = image_url('icon')
|
||||||
|
@account.header_remote_url = image_url('image')
|
||||||
|
@account.public_key = public_key || ''
|
||||||
|
@account.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def image_url(key)
|
||||||
|
value = first_of_value(@json[key])
|
||||||
|
|
||||||
|
return if value.nil?
|
||||||
|
return @json[key]['url'] if @json[key].is_a?(Hash)
|
||||||
|
|
||||||
|
image = fetch_resource(value)
|
||||||
|
image['url'] if image
|
||||||
|
end
|
||||||
|
|
||||||
|
def public_key
|
||||||
|
value = first_of_value(@json['publicKey'])
|
||||||
|
|
||||||
|
return if value.nil?
|
||||||
|
return value['publicKeyPem'] if value.is_a?(Hash)
|
||||||
|
|
||||||
|
key = fetch_resource(value)
|
||||||
|
key['publicKeyPem'] if key
|
||||||
|
end
|
||||||
|
|
||||||
|
def auto_suspend?
|
||||||
|
domain_block && domain_block.suspend?
|
||||||
|
end
|
||||||
|
|
||||||
|
def auto_silence?
|
||||||
|
domain_block && domain_block.silence?
|
||||||
|
end
|
||||||
|
|
||||||
|
def domain_block
|
||||||
|
return @domain_block if defined?(@domain_block)
|
||||||
|
@domain_block = DomainBlock.find_by(domain: @domain)
|
||||||
|
end
|
||||||
|
end
|
38
app/services/activitypub/process_collection_service.rb
Normal file
38
app/services/activitypub/process_collection_service.rb
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::ProcessCollectionService < BaseService
|
||||||
|
include JsonLdHelper
|
||||||
|
|
||||||
|
def call(body, account)
|
||||||
|
@account = account
|
||||||
|
@json = Oj.load(body, mode: :strict)
|
||||||
|
|
||||||
|
return if @account.suspended? || !supported_context?
|
||||||
|
|
||||||
|
case @json['type']
|
||||||
|
when 'Collection', 'CollectionPage'
|
||||||
|
process_items @json['items']
|
||||||
|
when 'OrderedCollection', 'OrderedCollectionPage'
|
||||||
|
process_items @json['orderedItems']
|
||||||
|
else
|
||||||
|
process_items [@json]
|
||||||
|
end
|
||||||
|
rescue Oj::ParseError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def process_items(items)
|
||||||
|
items.reverse_each.map { |item| process_item(item) }.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def supported_context?
|
||||||
|
super(@json)
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_item(item)
|
||||||
|
activity = ActivityPub::Activity.factory(item, @account)
|
||||||
|
activity&.perform
|
||||||
|
end
|
||||||
|
end
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
class ResolveRemoteAccountService < BaseService
|
class ResolveRemoteAccountService < BaseService
|
||||||
include OStatus2::MagicKey
|
include OStatus2::MagicKey
|
||||||
|
include JsonLdHelper
|
||||||
|
|
||||||
DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0'
|
DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0'
|
||||||
|
|
||||||
|
@ -12,6 +13,7 @@ class ResolveRemoteAccountService < BaseService
|
||||||
# @return [Account]
|
# @return [Account]
|
||||||
def call(uri, update_profile = true, redirected = nil)
|
def call(uri, update_profile = true, redirected = nil)
|
||||||
@username, @domain = uri.split('@')
|
@username, @domain = uri.split('@')
|
||||||
|
@update_profile = update_profile
|
||||||
|
|
||||||
return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
|
return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
|
||||||
|
|
||||||
|
@ -42,10 +44,11 @@ class ResolveRemoteAccountService < BaseService
|
||||||
if lock.acquired?
|
if lock.acquired?
|
||||||
@account = Account.find_remote(@username, @domain)
|
@account = Account.find_remote(@username, @domain)
|
||||||
|
|
||||||
create_account if @account.nil?
|
if activitypub_ready?
|
||||||
update_account
|
handle_activitypub
|
||||||
|
else
|
||||||
update_account_profile if update_profile
|
handle_ostatus
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -58,18 +61,47 @@ class ResolveRemoteAccountService < BaseService
|
||||||
private
|
private
|
||||||
|
|
||||||
def links_missing?
|
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('salmon').nil? ||
|
||||||
@webfinger.link('http://webfinger.net/rel/profile-page').nil? ||
|
@webfinger.link('http://webfinger.net/rel/profile-page').nil? ||
|
||||||
@webfinger.link('magic-public-key').nil? ||
|
@webfinger.link('magic-public-key').nil? ||
|
||||||
canonical_uri.nil? ||
|
canonical_uri.nil? ||
|
||||||
hub_url.nil?
|
hub_url.nil?)
|
||||||
end
|
end
|
||||||
|
|
||||||
def webfinger_update_due?
|
def webfinger_update_due?
|
||||||
@account.nil? || @account.last_webfingered_at.nil? || @account.last_webfingered_at <= 1.day.ago
|
@account.nil? || @account.last_webfingered_at.nil? || @account.last_webfingered_at <= 1.day.ago
|
||||||
end
|
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
|
def create_account
|
||||||
Rails.logger.debug "Creating new remote account for #{@username}@#{@domain}"
|
Rails.logger.debug "Creating new remote account for #{@username}@#{@domain}"
|
||||||
|
|
||||||
|
@ -81,6 +113,7 @@ class ResolveRemoteAccountService < BaseService
|
||||||
|
|
||||||
def update_account
|
def update_account
|
||||||
@account.last_webfingered_at = Time.now.utc
|
@account.last_webfingered_at = Time.now.utc
|
||||||
|
@account.protocol = :ostatus
|
||||||
@account.remote_url = atom_url
|
@account.remote_url = atom_url
|
||||||
@account.salmon_url = salmon_url
|
@account.salmon_url = salmon_url
|
||||||
@account.url = url
|
@account.url = url
|
||||||
|
@ -111,6 +144,10 @@ class ResolveRemoteAccountService < BaseService
|
||||||
@salmon_url ||= @webfinger.link('salmon').href
|
@salmon_url ||= @webfinger.link('salmon').href
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def actor_url
|
||||||
|
@actor_url ||= @webfinger.link('self').href
|
||||||
|
end
|
||||||
|
|
||||||
def url
|
def url
|
||||||
@url ||= @webfinger.link('http://webfinger.net/rel/profile-page').href
|
@url ||= @webfinger.link('http://webfinger.net/rel/profile-page').href
|
||||||
end
|
end
|
||||||
|
|
11
app/workers/activitypub/processing_worker.rb
Normal file
11
app/workers/activitypub/processing_worker.rb
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::ProcessingWorker
|
||||||
|
include Sidekiq::Worker
|
||||||
|
|
||||||
|
sidekiq_options backtrace: true
|
||||||
|
|
||||||
|
def perform(account_id, body)
|
||||||
|
ProcessCollectionService.new.call(body, Account.find(account_id))
|
||||||
|
end
|
||||||
|
end
|
17
app/workers/activitypub/thread_resolve_worker.rb
Normal file
17
app/workers/activitypub/thread_resolve_worker.rb
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::ThreadResolveWorker
|
||||||
|
include Sidekiq::Worker
|
||||||
|
|
||||||
|
sidekiq_options queue: 'pull', retry: false
|
||||||
|
|
||||||
|
def perform(child_status_id, parent_uri)
|
||||||
|
child_status = Status.find(child_status_id)
|
||||||
|
parent_status = ActivityPub::FetchRemoteStatusService.new.call(parent_uri)
|
||||||
|
|
||||||
|
return if parent_status.nil?
|
||||||
|
|
||||||
|
child_status.thread = parent_status
|
||||||
|
child_status.save!
|
||||||
|
end
|
||||||
|
end
|
|
@ -17,4 +17,5 @@ ActiveSupport::Inflector.inflections(:en) do |inflect|
|
||||||
inflect.acronym 'ActivityPub'
|
inflect.acronym 'ActivityPub'
|
||||||
inflect.acronym 'PubSubHubbub'
|
inflect.acronym 'PubSubHubbub'
|
||||||
inflect.acronym 'ActivityStreams'
|
inflect.acronym 'ActivityStreams'
|
||||||
|
inflect.acronym 'JsonLd'
|
||||||
end
|
end
|
||||||
|
|
|
@ -51,6 +51,7 @@ Rails.application.routes.draw do
|
||||||
resource :follow, only: [:create], controller: :account_follow
|
resource :follow, only: [:create], controller: :account_follow
|
||||||
resource :unfollow, only: [:create], controller: :account_unfollow
|
resource :unfollow, only: [:create], controller: :account_unfollow
|
||||||
resource :outbox, only: [:show], module: :activitypub
|
resource :outbox, only: [:show], module: :activitypub
|
||||||
|
resource :inbox, only: [:create], module: :activitypub
|
||||||
end
|
end
|
||||||
|
|
||||||
get '/@:username', to: 'accounts#show', as: :short_account
|
get '/@:username', to: 'accounts#show', as: :short_account
|
||||||
|
|
7
spec/controllers/activitypub/inboxes_controller_spec.rb
Normal file
7
spec/controllers/activitypub/inboxes_controller_spec.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ActivityPub::InboxesController, type: :controller do
|
||||||
|
describe 'POST #create' do
|
||||||
|
pending
|
||||||
|
end
|
||||||
|
end
|
19
spec/controllers/activitypub/outboxes_controller_spec.rb
Normal file
19
spec/controllers/activitypub/outboxes_controller_spec.rb
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ActivityPub::OutboxesController, type: :controller do
|
||||||
|
let!(:account) { Fabricate(:account) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
Fabricate(:status, account: account)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET #show' do
|
||||||
|
before do
|
||||||
|
get :show, params: { account_username: account.username }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns http success' do
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
35
spec/helpers/jsonld_helper_spec.rb
Normal file
35
spec/helpers/jsonld_helper_spec.rb
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe JsonLdHelper do
|
||||||
|
describe '#equals_or_includes?' do
|
||||||
|
it 'returns true when value equals' do
|
||||||
|
expect(helper.equals_or_includes?('foo', 'foo')).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false when value does not equal' do
|
||||||
|
expect(helper.equals_or_includes?('foo', 'bar')).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true when value is included' do
|
||||||
|
expect(helper.equals_or_includes?(%w(foo baz), 'foo')).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false when value is not included' do
|
||||||
|
expect(helper.equals_or_includes?(%w(foo baz), 'bar')).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#first_of_value' do
|
||||||
|
pending
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#supported_context?' do
|
||||||
|
pending
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#fetch_resource' do
|
||||||
|
pending
|
||||||
|
end
|
||||||
|
end
|
29
spec/lib/activitypub/activity/announce_spec.rb
Normal file
29
spec/lib/activitypub/activity/announce_spec.rb
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ActivityPub::Activity::Announce do
|
||||||
|
let(:sender) { Fabricate(:account) }
|
||||||
|
let(:recipient) { Fabricate(:account) }
|
||||||
|
let(:status) { Fabricate(:status, account: recipient) }
|
||||||
|
|
||||||
|
let(:json) do
|
||||||
|
{
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
id: 'foo',
|
||||||
|
type: 'Announce',
|
||||||
|
actor: ActivityPub::TagManager.instance.uri_for(sender),
|
||||||
|
object: ActivityPub::TagManager.instance.uri_for(status),
|
||||||
|
}.with_indifferent_access
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
subject { described_class.new(json, sender) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
subject.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a reblog by sender of status' do
|
||||||
|
expect(sender.reblogged?(status)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
28
spec/lib/activitypub/activity/block_spec.rb
Normal file
28
spec/lib/activitypub/activity/block_spec.rb
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ActivityPub::Activity::Block do
|
||||||
|
let(:sender) { Fabricate(:account) }
|
||||||
|
let(:recipient) { Fabricate(:account) }
|
||||||
|
|
||||||
|
let(:json) do
|
||||||
|
{
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
id: 'foo',
|
||||||
|
type: 'Block',
|
||||||
|
actor: ActivityPub::TagManager.instance.uri_for(sender),
|
||||||
|
object: ActivityPub::TagManager.instance.uri_for(recipient),
|
||||||
|
}.with_indifferent_access
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
subject { described_class.new(json, sender) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
subject.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a block from sender to recipient' do
|
||||||
|
expect(sender.blocking?(recipient)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
221
spec/lib/activitypub/activity/create_spec.rb
Normal file
221
spec/lib/activitypub/activity/create_spec.rb
Normal file
|
@ -0,0 +1,221 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ActivityPub::Activity::Create do
|
||||||
|
let(:sender) { Fabricate(:account, followers_url: 'http://example.com/followers') }
|
||||||
|
|
||||||
|
let(:json) do
|
||||||
|
{
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
id: 'foo',
|
||||||
|
type: 'Create',
|
||||||
|
actor: ActivityPub::TagManager.instance.uri_for(sender),
|
||||||
|
object: object_json,
|
||||||
|
}.with_indifferent_access
|
||||||
|
end
|
||||||
|
|
||||||
|
subject { described_class.new(json, sender) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt'))
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
before do
|
||||||
|
subject.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'standalone' do
|
||||||
|
let(:object_json) do
|
||||||
|
{
|
||||||
|
id: 'bar',
|
||||||
|
type: 'Note',
|
||||||
|
content: 'Lorem ipsum',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates status' do
|
||||||
|
status = sender.statuses.first
|
||||||
|
|
||||||
|
expect(status).to_not be_nil
|
||||||
|
expect(status.text).to eq 'Lorem ipsum'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'missing to/cc defaults to direct privacy' do
|
||||||
|
status = sender.statuses.first
|
||||||
|
|
||||||
|
expect(status).to_not be_nil
|
||||||
|
expect(status.visibility).to eq 'direct'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'public' do
|
||||||
|
let(:object_json) do
|
||||||
|
{
|
||||||
|
id: 'bar',
|
||||||
|
type: 'Note',
|
||||||
|
content: 'Lorem ipsum',
|
||||||
|
to: 'https://www.w3.org/ns/activitystreams#Public',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates status' do
|
||||||
|
status = sender.statuses.first
|
||||||
|
|
||||||
|
expect(status).to_not be_nil
|
||||||
|
expect(status.visibility).to eq 'public'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'unlisted' do
|
||||||
|
let(:object_json) do
|
||||||
|
{
|
||||||
|
id: 'bar',
|
||||||
|
type: 'Note',
|
||||||
|
content: 'Lorem ipsum',
|
||||||
|
cc: 'https://www.w3.org/ns/activitystreams#Public',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates status' do
|
||||||
|
status = sender.statuses.first
|
||||||
|
|
||||||
|
expect(status).to_not be_nil
|
||||||
|
expect(status.visibility).to eq 'unlisted'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'private' do
|
||||||
|
let(:object_json) do
|
||||||
|
{
|
||||||
|
id: 'bar',
|
||||||
|
type: 'Note',
|
||||||
|
content: 'Lorem ipsum',
|
||||||
|
to: 'http://example.com/followers',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates status' do
|
||||||
|
status = sender.statuses.first
|
||||||
|
|
||||||
|
expect(status).to_not be_nil
|
||||||
|
expect(status.visibility).to eq 'private'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'direct' do
|
||||||
|
let(:recipient) { Fabricate(:account) }
|
||||||
|
|
||||||
|
let(:object_json) do
|
||||||
|
{
|
||||||
|
id: 'bar',
|
||||||
|
type: 'Note',
|
||||||
|
content: 'Lorem ipsum',
|
||||||
|
to: ActivityPub::TagManager.instance.uri_for(recipient),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates status' do
|
||||||
|
status = sender.statuses.first
|
||||||
|
|
||||||
|
expect(status).to_not be_nil
|
||||||
|
expect(status.visibility).to eq 'direct'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'as a reply' do
|
||||||
|
let(:original_status) { Fabricate(:status) }
|
||||||
|
|
||||||
|
let(:object_json) do
|
||||||
|
{
|
||||||
|
id: 'bar',
|
||||||
|
type: 'Note',
|
||||||
|
content: 'Lorem ipsum',
|
||||||
|
inReplyTo: ActivityPub::TagManager.instance.uri_for(original_status),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates status' do
|
||||||
|
status = sender.statuses.first
|
||||||
|
|
||||||
|
expect(status).to_not be_nil
|
||||||
|
expect(status.thread).to eq original_status
|
||||||
|
expect(status.reply?).to be true
|
||||||
|
expect(status.in_reply_to_account).to eq original_status.account
|
||||||
|
expect(status.conversation).to eq original_status.conversation
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with mentions' do
|
||||||
|
let(:recipient) { Fabricate(:account) }
|
||||||
|
|
||||||
|
let(:object_json) do
|
||||||
|
{
|
||||||
|
id: 'bar',
|
||||||
|
type: 'Note',
|
||||||
|
content: 'Lorem ipsum',
|
||||||
|
tag: [
|
||||||
|
{
|
||||||
|
type: 'Mention',
|
||||||
|
href: ActivityPub::TagManager.instance.uri_for(recipient),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates status' do
|
||||||
|
status = sender.statuses.first
|
||||||
|
|
||||||
|
expect(status).to_not be_nil
|
||||||
|
expect(status.mentions.map(&:account)).to include(recipient)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with media attachments' do
|
||||||
|
let(:object_json) do
|
||||||
|
{
|
||||||
|
id: 'bar',
|
||||||
|
type: 'Note',
|
||||||
|
content: 'Lorem ipsum',
|
||||||
|
attachment: [
|
||||||
|
{
|
||||||
|
type: 'Document',
|
||||||
|
mime_type: 'image/png',
|
||||||
|
url: 'http://example.com/attachment.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates status' do
|
||||||
|
status = sender.statuses.first
|
||||||
|
|
||||||
|
expect(status).to_not be_nil
|
||||||
|
expect(status.media_attachments.map(&:remote_url)).to include('http://example.com/attachment.png')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with hashtags' do
|
||||||
|
let(:object_json) do
|
||||||
|
{
|
||||||
|
id: 'bar',
|
||||||
|
type: 'Note',
|
||||||
|
content: 'Lorem ipsum',
|
||||||
|
tag: [
|
||||||
|
{
|
||||||
|
type: 'Hashtag',
|
||||||
|
href: 'http://example.com/blah',
|
||||||
|
name: '#test',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates status' do
|
||||||
|
status = sender.statuses.first
|
||||||
|
|
||||||
|
expect(status).to_not be_nil
|
||||||
|
expect(status.tags.map(&:name)).to include('test')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
28
spec/lib/activitypub/activity/delete_spec.rb
Normal file
28
spec/lib/activitypub/activity/delete_spec.rb
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ActivityPub::Activity::Delete do
|
||||||
|
let(:sender) { Fabricate(:account) }
|
||||||
|
let(:status) { Fabricate(:status, account: sender, uri: 'foobar') }
|
||||||
|
|
||||||
|
let(:json) do
|
||||||
|
{
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
id: 'foo',
|
||||||
|
type: 'Delete',
|
||||||
|
actor: ActivityPub::TagManager.instance.uri_for(sender),
|
||||||
|
object: ActivityPub::TagManager.instance.uri_for(status),
|
||||||
|
}.with_indifferent_access
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
subject { described_class.new(json, sender) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
subject.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'deletes sender\'s status' do
|
||||||
|
expect(Status.find_by(id: status.id)).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
28
spec/lib/activitypub/activity/follow_spec.rb
Normal file
28
spec/lib/activitypub/activity/follow_spec.rb
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ActivityPub::Activity::Follow do
|
||||||
|
let(:sender) { Fabricate(:account) }
|
||||||
|
let(:recipient) { Fabricate(:account) }
|
||||||
|
|
||||||
|
let(:json) do
|
||||||
|
{
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
id: 'foo',
|
||||||
|
type: 'Follow',
|
||||||
|
actor: ActivityPub::TagManager.instance.uri_for(sender),
|
||||||
|
object: ActivityPub::TagManager.instance.uri_for(recipient),
|
||||||
|
}.with_indifferent_access
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
subject { described_class.new(json, sender) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
subject.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a follow from sender to recipient' do
|
||||||
|
expect(sender.following?(recipient)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
29
spec/lib/activitypub/activity/like_spec.rb
Normal file
29
spec/lib/activitypub/activity/like_spec.rb
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ActivityPub::Activity::Like do
|
||||||
|
let(:sender) { Fabricate(:account) }
|
||||||
|
let(:recipient) { Fabricate(:account) }
|
||||||
|
let(:status) { Fabricate(:status, account: recipient) }
|
||||||
|
|
||||||
|
let(:json) do
|
||||||
|
{
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
id: 'foo',
|
||||||
|
type: 'Like',
|
||||||
|
actor: ActivityPub::TagManager.instance.uri_for(sender),
|
||||||
|
object: ActivityPub::TagManager.instance.uri_for(status),
|
||||||
|
}.with_indifferent_access
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
subject { described_class.new(json, sender) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
subject.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a favourite from sender to status' do
|
||||||
|
expect(sender.favourited?(status)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
107
spec/lib/activitypub/activity/undo_spec.rb
Normal file
107
spec/lib/activitypub/activity/undo_spec.rb
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ActivityPub::Activity::Undo do
|
||||||
|
let(:sender) { Fabricate(:account) }
|
||||||
|
|
||||||
|
let(:json) do
|
||||||
|
{
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
id: 'foo',
|
||||||
|
type: 'Undo',
|
||||||
|
actor: ActivityPub::TagManager.instance.uri_for(sender),
|
||||||
|
object: object_json,
|
||||||
|
}.with_indifferent_access
|
||||||
|
end
|
||||||
|
|
||||||
|
subject { described_class.new(json, sender) }
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
context 'with Announce' do
|
||||||
|
let(:status) { Fabricate(:status) }
|
||||||
|
|
||||||
|
let(:object_json) do
|
||||||
|
{
|
||||||
|
id: 'bar',
|
||||||
|
type: 'Announce',
|
||||||
|
actor: ActivityPub::TagManager.instance.uri_for(sender),
|
||||||
|
object: ActivityPub::TagManager.instance.uri_for(status),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
Fabricate(:status, reblog: status, account: sender, uri: 'bar')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'deletes the reblog' do
|
||||||
|
subject.perform
|
||||||
|
expect(sender.reblogged?(status)).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with Block' do
|
||||||
|
let(:recipient) { Fabricate(:account) }
|
||||||
|
|
||||||
|
let(:object_json) do
|
||||||
|
{
|
||||||
|
id: 'bar',
|
||||||
|
type: 'Block',
|
||||||
|
actor: ActivityPub::TagManager.instance.uri_for(sender),
|
||||||
|
object: ActivityPub::TagManager.instance.uri_for(recipient),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
sender.block!(recipient)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'deletes block from sender to recipient' do
|
||||||
|
subject.perform
|
||||||
|
expect(sender.blocking?(recipient)).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with Follow' do
|
||||||
|
let(:recipient) { Fabricate(:account) }
|
||||||
|
|
||||||
|
let(:object_json) do
|
||||||
|
{
|
||||||
|
id: 'bar',
|
||||||
|
type: 'Follow',
|
||||||
|
actor: ActivityPub::TagManager.instance.uri_for(sender),
|
||||||
|
object: ActivityPub::TagManager.instance.uri_for(recipient),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
sender.follow!(recipient)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'deletes follow from sender to recipient' do
|
||||||
|
subject.perform
|
||||||
|
expect(sender.following?(recipient)).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with Like' do
|
||||||
|
let(:status) { Fabricate(:status) }
|
||||||
|
|
||||||
|
let(:object_json) do
|
||||||
|
{
|
||||||
|
id: 'bar',
|
||||||
|
type: 'Like',
|
||||||
|
actor: ActivityPub::TagManager.instance.uri_for(sender),
|
||||||
|
object: ActivityPub::TagManager.instance.uri_for(status),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
Fabricate(:favourite, account: sender, status: status)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'deletes favourite from sender to status' do
|
||||||
|
subject.perform
|
||||||
|
expect(sender.favourited?(status)).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
41
spec/lib/activitypub/activity/update_spec.rb
Normal file
41
spec/lib/activitypub/activity/update_spec.rb
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ActivityPub::Activity::Update do
|
||||||
|
let!(:sender) { Fabricate(:account) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
sender.update!(uri: ActivityPub::TagManager.instance.uri_for(sender))
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:modified_sender) do
|
||||||
|
sender.dup.tap do |modified_sender|
|
||||||
|
modified_sender.display_name = 'Totally modified now'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:actor_json) do
|
||||||
|
ActiveModelSerializers::SerializableResource.new(modified_sender, serializer: ActivityPub::ActorSerializer, key_transform: :camel_lower).as_json
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:json) do
|
||||||
|
{
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
id: 'foo',
|
||||||
|
type: 'Update',
|
||||||
|
actor: ActivityPub::TagManager.instance.uri_for(sender),
|
||||||
|
object: actor_json,
|
||||||
|
}.with_indifferent_access
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
subject { described_class.new(json, sender) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
subject.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates profile' do
|
||||||
|
expect(sender.reload.display_name).to eq 'Totally modified now'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
99
spec/lib/activitypub/tag_manager_spec.rb
Normal file
99
spec/lib/activitypub/tag_manager_spec.rb
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ActivityPub::TagManager do
|
||||||
|
include RoutingHelper
|
||||||
|
|
||||||
|
subject { described_class.instance }
|
||||||
|
|
||||||
|
describe '#url_for' do
|
||||||
|
it 'returns a string' do
|
||||||
|
account = Fabricate(:account)
|
||||||
|
expect(subject.url_for(account)).to be_a String
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#uri_for' do
|
||||||
|
it 'returns a string' do
|
||||||
|
account = Fabricate(:account)
|
||||||
|
expect(subject.uri_for(account)).to be_a String
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#to' do
|
||||||
|
it 'returns public collection for public status' do
|
||||||
|
status = Fabricate(:status, visibility: :public)
|
||||||
|
expect(subject.to(status)).to eq ['https://www.w3.org/ns/activitystreams#Public']
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns followers collection for unlisted status' do
|
||||||
|
status = Fabricate(:status, visibility: :unlisted)
|
||||||
|
expect(subject.to(status)).to eq [account_followers_url(status.account)]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns followers collection for private status' do
|
||||||
|
status = Fabricate(:status, visibility: :private)
|
||||||
|
expect(subject.to(status)).to eq [account_followers_url(status.account)]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns URIs of mentions for direct status' do
|
||||||
|
status = Fabricate(:status, visibility: :direct)
|
||||||
|
mentioned = Fabricate(:account)
|
||||||
|
status.mentions.create(account: mentioned)
|
||||||
|
expect(subject.to(status)).to eq [subject.uri_for(mentioned)]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#cc' do
|
||||||
|
it 'returns followers collection for public status' do
|
||||||
|
status = Fabricate(:status, visibility: :public)
|
||||||
|
expect(subject.cc(status)).to eq [account_followers_url(status.account)]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns public collection for unlisted status' do
|
||||||
|
status = Fabricate(:status, visibility: :unlisted)
|
||||||
|
expect(subject.cc(status)).to eq ['https://www.w3.org/ns/activitystreams#Public']
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns empty array for private status' do
|
||||||
|
status = Fabricate(:status, visibility: :private)
|
||||||
|
expect(subject.cc(status)).to eq []
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns empty array for direct status' do
|
||||||
|
status = Fabricate(:status, visibility: :direct)
|
||||||
|
expect(subject.cc(status)).to eq []
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns URIs of mentions for non-direct status' do
|
||||||
|
status = Fabricate(:status, visibility: :public)
|
||||||
|
mentioned = Fabricate(:account)
|
||||||
|
status.mentions.create(account: mentioned)
|
||||||
|
expect(subject.cc(status)).to include(subject.uri_for(mentioned))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#local_uri?' do
|
||||||
|
it 'returns false for non-local URI' do
|
||||||
|
expect(subject.local_uri?('http://example.com/123')).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true for local URIs' do
|
||||||
|
account = Fabricate(:account)
|
||||||
|
expect(subject.local_uri?(subject.uri_for(account))).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#uri_to_local_id' do
|
||||||
|
it 'returns the local ID' do
|
||||||
|
account = Fabricate(:account)
|
||||||
|
expect(subject.uri_to_local_id(subject.uri_for(account), :username)).to eq account.username
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#uri_to_resource' do
|
||||||
|
it 'returns the local resource' do
|
||||||
|
account = Fabricate(:account)
|
||||||
|
expect(subject.uri_to_resource(subject.uri_for(account), Account)).to eq account
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,96 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ActivityPub::FetchRemoteAccountService do
|
||||||
|
subject { ActivityPub::FetchRemoteAccountService.new }
|
||||||
|
|
||||||
|
let!(:actor) do
|
||||||
|
{
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
id: 'https://example.com/alice',
|
||||||
|
type: 'Person',
|
||||||
|
preferredUsername: 'alice',
|
||||||
|
name: 'Alice',
|
||||||
|
summary: 'Foo bar',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#call' do
|
||||||
|
let(:account) { subject.call('https://example.com/alice') }
|
||||||
|
|
||||||
|
shared_examples 'sets profile data' do
|
||||||
|
it 'returns an account' do
|
||||||
|
expect(account).to be_an Account
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets display name' do
|
||||||
|
expect(account.display_name).to eq 'Alice'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets note' do
|
||||||
|
expect(account.note).to eq 'Foo bar'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets URL' do
|
||||||
|
expect(account.url).to eq 'https://example.com/alice'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when URI and WebFinger share the same host' do
|
||||||
|
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
|
||||||
|
|
||||||
|
before do
|
||||||
|
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
|
||||||
|
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fetches resource' do
|
||||||
|
account
|
||||||
|
expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'looks up webfinger' do
|
||||||
|
account
|
||||||
|
expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets username and domain from webfinger' do
|
||||||
|
expect(account.username).to eq 'alice'
|
||||||
|
expect(account.domain).to eq 'example.com'
|
||||||
|
end
|
||||||
|
|
||||||
|
include_examples 'sets profile data'
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when WebFinger presents different domain than URI' do
|
||||||
|
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
|
||||||
|
|
||||||
|
before do
|
||||||
|
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
|
||||||
|
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||||
|
stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fetches resource' do
|
||||||
|
account
|
||||||
|
expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'looks up webfinger' do
|
||||||
|
account
|
||||||
|
expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'looks up "redirected" webfinger' do
|
||||||
|
account
|
||||||
|
expect(a_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af')).to have_been_made.once
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets username and domain from final webfinger' do
|
||||||
|
expect(account.username).to eq 'alice'
|
||||||
|
expect(account.domain).to eq 'iscool.af'
|
||||||
|
end
|
||||||
|
|
||||||
|
include_examples 'sets profile data'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,5 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ActivityPub::FetchRemoteStatusService do
|
||||||
|
pending
|
||||||
|
end
|
|
@ -0,0 +1,5 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ActivityPub::ProcessAccountService do
|
||||||
|
pending
|
||||||
|
end
|
|
@ -0,0 +1,9 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ActivityPub::ProcessCollectionService do
|
||||||
|
subject { ActivityPub::ProcessCollectionService.new }
|
||||||
|
|
||||||
|
describe '#call' do
|
||||||
|
pending
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in a new issue