Merge branch 'origin/master' into sync/upstream

Conflicts:
	app/javascript/mastodon/components/status_list.js
	app/javascript/mastodon/features/notifications/index.js
	app/javascript/mastodon/features/ui/components/modal_root.js
	app/javascript/mastodon/features/ui/components/onboarding_modal.js
	app/javascript/mastodon/features/ui/index.js
	app/javascript/styles/about.scss
	app/javascript/styles/accounts.scss
	app/javascript/styles/components.scss
	app/presenters/instance_presenter.rb
	app/services/post_status_service.rb
	app/services/reblog_service.rb
	app/views/about/more.html.haml
	app/views/about/show.html.haml
	app/views/accounts/_header.html.haml
	config/webpack/loaders/babel.js
	spec/controllers/api/v1/accounts/credentials_controller_spec.rb
This commit is contained in:
David Yip
2017-09-09 14:27:47 -05:00
352 changed files with 8629 additions and 2380 deletions

View 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, prefetched_json = nil)
@json = body_to_json(prefetched_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

View File

@@ -0,0 +1,47 @@
# frozen_string_literal: true
class ActivityPub::FetchRemoteKeyService < BaseService
include JsonLdHelper
# Returns account that owns the key
def call(uri, prefetched_json = nil)
@json = body_to_json(prefetched_json) || fetch_resource(uri)
return unless supported_context?(@json) && expected_type?
return find_account(uri, @json) if person?
@owner = fetch_resource(owner_uri)
return unless supported_context?(@owner) && confirmed_owner?
find_account(owner_uri, @owner)
end
private
def find_account(uri, prefetched_json)
account = ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_json)
account
end
def expected_type?
person? || public_key?
end
def person?
@json['type'] == 'Person'
end
def public_key?
@json['publicKeyPem'].present? && @json['owner'].present?
end
def owner_uri
@owner_uri ||= value_or_id(@json['owner'])
end
def confirmed_owner?
@owner['type'] == 'Person' && value_or_id(@owner['publicKey']) == @json['id']
end
end

View File

@@ -0,0 +1,48 @@
# frozen_string_literal: true
class ActivityPub::FetchRemoteStatusService < BaseService
include JsonLdHelper
# Should be called when uri has already been checked for locality
def call(uri, prefetched_json = nil)
@json = body_to_json(prefetched_json) || fetch_resource(uri)
return unless supported_context?
activity = activity_json
actor_id = value_or_id(activity['actor'])
return unless expected_type?(activity) && trustworthy_attribution?(uri, actor_id)
actor = ActivityPub::TagManager.instance.uri_to_resource(actor_id, Account)
actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id) if actor.nil?
ActivityPub::Activity.factory(activity, actor).perform
end
private
def activity_json
if %w(Note Article).include? @json['type']
{
'type' => 'Create',
'actor' => first_of_value(@json['attributedTo']),
'object' => @json,
}
else
@json
end
end
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?(json)
%w(Create Announce).include? json['type']
end
end

View File

@@ -0,0 +1,93 @@
# 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)
return unless json['inbox'].present?
@json = json
@uri = @json['id']
@username = username
@domain = domain
@account = Account.find_by(uri: @uri)
create_account if @account.nil?
upgrade_account if @account.ostatus?
update_account
@account
rescue Oj::ParseError
nil
end
private
def create_account
@account = Account.new
@account.protocol = :activitypub
@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.locked = @json['manuallyApprovesFollowers'] || false
@account.save!
end
def upgrade_account
ActivityPub::PostUpgradeWorker.perform_async(@account.domain)
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

View File

@@ -0,0 +1,48 @@
# 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?
return if different_actor? && verify_account!.nil?
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 different_actor?
@json['actor'].present? && value_or_id(@json['actor']) != @account.uri && @json['signature'].present?
end
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
def verify_account!
@account = ActivityPub::LinkedDataSignature.new(@json).verify_account!
end
end

View File

@@ -1,14 +1,36 @@
# frozen_string_literal: true
class AuthorizeFollowService < BaseService
def call(source_account, target_account)
follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
follow_request.authorize!
NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local?
def call(source_account, target_account, options = {})
if options[:skip_follow_request]
follow_request = FollowRequest.new(account: source_account, target_account: target_account)
else
follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
follow_request.authorize!
end
create_notification(follow_request) unless source_account.local?
follow_request
end
private
def create_notification(follow_request)
if follow_request.account.ostatus?
NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id)
elsif follow_request.account.activitypub?
ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url)
end
end
def build_json(follow_request)
Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
follow_request,
serializer: ActivityPub::AcceptFollowSerializer,
adapter: ActivityPub::Adapter
).as_json).sign!(follow_request.target_account))
end
def build_xml(follow_request)
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.authorize_follow_request_salmon(follow_request))
end

View File

@@ -15,19 +15,26 @@ class BatchedRemoveStatusService < BaseService
@mentions = statuses.map { |s| [s.id, s.mentions.includes(:account).to_a] }.to_h
@tags = statuses.map { |s| [s.id, s.tags.pluck(:name)] }.to_h
@stream_entry_batches = []
@salmon_batches = []
@json_payloads = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id)] }.to_h
@stream_entry_batches = []
@salmon_batches = []
@activity_json_batches = []
@json_payloads = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id)] }.to_h
@activity_json = {}
@activity_xml = {}
# Ensure that rendered XML reflects destroyed state
Status.where(id: statuses.map(&:id)).in_batches.destroy_all
statuses.each(&:destroy)
# Batch by source account
statuses.group_by(&:account_id).each do |_, account_statuses|
account = account_statuses.first.account
unpush_from_home_timelines(account_statuses)
batch_stream_entries(account_statuses) if account.local?
if account.local?
batch_stream_entries(account, account_statuses)
batch_activity_json(account, account_statuses)
end
end
# Cannot be batched
@@ -36,17 +43,32 @@ class BatchedRemoveStatusService < BaseService
batch_salmon_slaps(status) if status.local?
end
Pubsubhubbub::DistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch }
Pubsubhubbub::RawDistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch }
NotificationWorker.push_bulk(@salmon_batches) { |batch| batch }
ActivityPub::DeliveryWorker.push_bulk(@activity_json_batches) { |batch| batch }
end
private
def batch_stream_entries(statuses)
stream_entry_ids = statuses.map { |s| s.stream_entry.id }
def batch_stream_entries(account, statuses)
statuses.each do |status|
@stream_entry_batches << [build_xml(status.stream_entry), account.id]
end
end
stream_entry_ids.each_slice(100) do |batch_of_stream_entry_ids|
@stream_entry_batches << [batch_of_stream_entry_ids]
def batch_activity_json(account, statuses)
account.followers.inboxes.each do |inbox_url|
statuses.each do |status|
@activity_json_batches << [build_json(status), account.id, inbox_url]
end
end
statuses.each do |status|
other_recipients = (status.mentions + status.reblogs).map(&:account).reject(&:local?).select(&:activitypub?).uniq(&:id)
other_recipients.each do |target_account|
@activity_json_batches << [build_json(status), account.id, target_account.inbox_url]
end
end
end
@@ -78,11 +100,10 @@ class BatchedRemoveStatusService < BaseService
def batch_salmon_slaps(status)
return if @mentions[status.id].empty?
payload = stream_entry_to_xml(status.stream_entry.reload)
recipients = @mentions[status.id].map(&:account).reject(&:local?).uniq(&:domain).map(&:id)
recipients = @mentions[status.id].map(&:account).reject(&:local?).select(&:ostatus?).uniq(&:domain).map(&:id)
recipients.each do |recipient_id|
@salmon_batches << [payload, status.account_id, recipient_id]
@salmon_batches << [build_xml(status.stream_entry), status.account_id, recipient_id]
end
end
@@ -111,4 +132,24 @@ class BatchedRemoveStatusService < BaseService
def redis
Redis.current
end
def build_json(status)
return @activity_json[status.id] if @activity_json.key?(status.id)
@activity_json[status.id] = sign_json(status, ActiveModelSerializers::SerializableResource.new(
status,
serializer: status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer,
adapter: ActivityPub::Adapter
).as_json)
end
def build_xml(stream_entry)
return @activity_xml[stream_entry.id] if @activity_xml.key?(stream_entry.id)
@activity_xml[stream_entry.id] = stream_entry_to_xml(stream_entry)
end
def sign_json(status, json)
Oj.dump(ActivityPub::LinkedDataSignature.new(json).sign!(status.account))
end
end

View File

@@ -30,7 +30,7 @@ class BlockDomainService < BaseService
def suspend_accounts!
blocked_domain_accounts.where(suspended: false).find_each do |account|
account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed?
UnsubscribeService.new.call(account) if account.subscribed?
SuspendAccountService.new.call(account)
end
end

View File

@@ -12,11 +12,28 @@ class BlockService < BaseService
block = account.block!(target_account)
BlockWorker.perform_async(account.id, target_account.id)
NotificationWorker.perform_async(build_xml(block), account.id, target_account.id) unless target_account.local?
create_notification(block) unless target_account.local?
block
end
private
def create_notification(block)
if block.target_account.ostatus?
NotificationWorker.perform_async(build_xml(block), block.account_id, block.target_account_id)
elsif block.target_account.activitypub?
ActivityPub::DeliveryWorker.perform_async(build_json(block), block.account_id, block.target_account.inbox_url)
end
end
def build_json(block)
Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
block,
serializer: ActivityPub::BlockSerializer,
adapter: ActivityPub::Adapter
).as_json).sign!(block.account))
end
def build_xml(block)
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.block_salmon(block))
end

View File

@@ -15,18 +15,32 @@ class FavouriteService < BaseService
return favourite unless favourite.nil?
favourite = Favourite.create!(account: account, status: status)
if status.local?
NotifyService.new.call(favourite.status.account, favourite)
else
NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id)
end
create_notification(favourite)
favourite
end
private
def create_notification(favourite)
status = favourite.status
if status.account.local?
NotifyService.new.call(status.account, favourite)
elsif status.account.ostatus?
NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id)
elsif status.account.activitypub?
ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url)
end
end
def build_json(favourite)
Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
favourite,
serializer: ActivityPub::LikeSerializer,
adapter: ActivityPub::Adapter
).as_json).sign!(favourite.account))
end
def build_xml(favourite)
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.favourite_salmon(favourite))
end

View File

@@ -1,21 +1,17 @@
# frozen_string_literal: true
class FetchAtomService < BaseService
include JsonLdHelper
def call(url)
return if url.blank?
response = Request.new(:head, url).perform
result = process(url)
Rails.logger.debug "Remote status HEAD request returned code #{response.code}"
# retry without ActivityPub
result ||= process(url) if @unsupported_activity
response = Request.new(:get, url).perform if response.code == 405
Rails.logger.debug "Remote status GET request returned code #{response.code}"
return nil if response.code != 200
return [url, fetch(url)] if response.mime_type == 'application/atom+xml'
return process_headers(url, response) if response['Link'].present?
process_html(fetch(url))
result
rescue OpenSSL::SSL::SSLError => e
Rails.logger.debug "SSL error: #{e}"
nil
@@ -26,27 +22,67 @@ class FetchAtomService < BaseService
private
def process_html(body)
Rails.logger.debug 'Processing HTML'
page = Nokogiri::HTML(body)
alternate_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' }
return nil if alternate_link.nil?
[alternate_link['href'], fetch(alternate_link['href'])]
def process(url, terminal = false)
@url = url
perform_request
process_response(terminal)
end
def process_headers(url, response)
Rails.logger.debug 'Processing link header'
def perform_request
accept = 'text/html'
accept = 'application/activity+json, application/ld+json, application/atom+xml, ' + accept unless @unsupported_activity
link_header = LinkHeader.parse(response['Link'].is_a?(Array) ? response['Link'].first : response['Link'])
alternate_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml))
return process_html(fetch(url)) if alternate_link.nil?
[alternate_link.href, fetch(alternate_link.href)]
@response = Request.new(:get, @url)
.add_headers('Accept' => accept)
.perform
end
def fetch(url)
Request.new(:get, url).perform.to_s
def process_response(terminal = false)
return nil if @response.code != 200
if @response.mime_type == 'application/atom+xml'
[@url, @response.to_s, :ostatus]
elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@response.mime_type)
if supported_activity?(@response.to_s)
[@url, @response.to_s, :activitypub]
else
@unsupported_activity = true
nil
end
elsif @response['Link'] && !terminal
process_headers
elsif @response.mime_type == 'text/html' && !terminal
process_html
end
end
def process_html
page = Nokogiri::HTML(@response.to_s)
json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) }
atom_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' }
result ||= process(json_link['href'], terminal: true) unless json_link.nil? || @unsupported_activity
result ||= process(atom_link['href'], terminal: true) unless atom_link.nil?
result
end
def process_headers
link_header = LinkHeader.parse(@response['Link'].is_a?(Array) ? @response['Link'].first : @response['Link'])
json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'])
atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml))
result ||= process(json_link.href, terminal: true) unless json_link.nil? || @unsupported_activity
result ||= process(atom_link.href, terminal: true) unless atom_link.nil?
result
end
def supported_activity?(body)
json = body_to_json(body)
return false unless supported_context?(json)
json['type'] == 'Person' ? json['inbox'].present? : true
end
end

View File

@@ -4,29 +4,45 @@ class FetchLinkCardService < BaseService
URL_PATTERN = %r{https?://\S+}
def call(status)
# Get first http/https URL that isn't local
url = parse_urls(status)
@status = status
@url = parse_urls
return if url.nil?
return if @url.nil? || @status.preview_cards.any?
url = url.to_s
card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url)
res = Request.new(:head, url).perform
@url = @url.to_s
return if res.code != 200 || res.mime_type != 'text/html'
RedisLock.acquire(lock_options) do |lock|
if lock.acquired?
@card = PreviewCard.find_by(url: @url)
process_url if @card.nil?
end
end
attempt_opengraph(card, url) unless attempt_oembed(card, url)
attach_card unless @card.nil?
rescue HTTP::ConnectionError, OpenSSL::SSL::SSLError
nil
end
private
def parse_urls(status)
if status.local?
urls = status.text.match(URL_PATTERN).to_a.map { |uri| Addressable::URI.parse(uri).normalize }
def process_url
@card = PreviewCard.new(url: @url)
res = Request.new(:head, @url).perform
return if res.code != 200 || res.mime_type != 'text/html'
attempt_oembed || attempt_opengraph
end
def attach_card
@status.preview_cards << @card
end
def parse_urls
if @status.local?
urls = @status.text.match(URL_PATTERN).to_a.map { |uri| Addressable::URI.parse(uri).normalize }
else
html = Nokogiri::HTML(status.text)
html = Nokogiri::HTML(@status.text)
links = html.css('a')
urls = links.map { |a| Addressable::URI.parse(a['href']).normalize unless skip_link?(a) }.compact
end
@@ -44,41 +60,41 @@ class FetchLinkCardService < BaseService
a['rel']&.include?('tag') || a['class']&.include?('u-url')
end
def attempt_oembed(card, url)
response = OEmbed::Providers.get(url)
def attempt_oembed
response = OEmbed::Providers.get(@url)
card.type = response.type
card.title = response.respond_to?(:title) ? response.title : ''
card.author_name = response.respond_to?(:author_name) ? response.author_name : ''
card.author_url = response.respond_to?(:author_url) ? response.author_url : ''
card.provider_name = response.respond_to?(:provider_name) ? response.provider_name : ''
card.provider_url = response.respond_to?(:provider_url) ? response.provider_url : ''
card.width = 0
card.height = 0
@card.type = response.type
@card.title = response.respond_to?(:title) ? response.title : ''
@card.author_name = response.respond_to?(:author_name) ? response.author_name : ''
@card.author_url = response.respond_to?(:author_url) ? response.author_url : ''
@card.provider_name = response.respond_to?(:provider_name) ? response.provider_name : ''
@card.provider_url = response.respond_to?(:provider_url) ? response.provider_url : ''
@card.width = 0
@card.height = 0
case card.type
case @card.type
when 'link'
card.image = URI.parse(response.thumbnail_url) if response.respond_to?(:thumbnail_url)
@card.image = URI.parse(response.thumbnail_url) if response.respond_to?(:thumbnail_url)
when 'photo'
card.url = response.url
card.width = response.width.presence || 0
card.height = response.height.presence || 0
@card.url = response.url
@card.width = response.width.presence || 0
@card.height = response.height.presence || 0
when 'video'
card.width = response.width.presence || 0
card.height = response.height.presence || 0
card.html = Formatter.instance.sanitize(response.html, Sanitize::Config::MASTODON_OEMBED)
@card.width = response.width.presence || 0
@card.height = response.height.presence || 0
@card.html = Formatter.instance.sanitize(response.html, Sanitize::Config::MASTODON_OEMBED)
when 'rich'
# Most providers rely on <script> tags, which is a no-no
return false
end
card.save_with_optional_image!
@card.save_with_optional_image!
rescue OEmbed::NotFound
false
end
def attempt_opengraph(card, url)
response = Request.new(:get, url).perform
def attempt_opengraph
response = Request.new(:get, @url).perform
return if response.code != 200 || response.mime_type != 'text/html'
@@ -88,19 +104,23 @@ class FetchLinkCardService < BaseService
detector.strip_tags = true
guess = detector.detect(html, response.charset)
page = Nokogiri::HTML(html, nil, guess&.fetch(:encoding))
page = Nokogiri::HTML(html, nil, guess&.fetch(:encoding))
card.type = :link
card.title = meta_property(page, 'og:title') || page.at_xpath('//title')&.content
card.description = meta_property(page, 'og:description') || meta_property(page, 'description')
card.image_remote_url = meta_property(page, 'og:image') if meta_property(page, 'og:image')
@card.type = :link
@card.title = meta_property(page, 'og:title') || page.at_xpath('//title')&.content || ''
@card.description = meta_property(page, 'og:description') || meta_property(page, 'description') || ''
@card.image_remote_url = meta_property(page, 'og:image') if meta_property(page, 'og:image')
return if card.title.blank?
return if @card.title.blank?
card.save_with_optional_image!
@card.save_with_optional_image!
end
def meta_property(html, property)
html.at_xpath("//meta[@property=\"#{property}\"]")&.attribute('content')&.value || html.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value
end
def lock_options
{ redis: Redis.current, key: "fetch:#{@url}" }
end
end

View File

@@ -3,16 +3,20 @@
class FetchRemoteAccountService < BaseService
include AuthorExtractor
def call(url, prefetched_body = nil)
def call(url, prefetched_body = nil, protocol = :ostatus)
if prefetched_body.nil?
atom_url, body = FetchAtomService.new.call(url)
resource_url, body, protocol = FetchAtomService.new.call(url)
else
atom_url = url
body = prefetched_body
resource_url = url
body = prefetched_body
end
return nil if atom_url.nil?
process_atom(atom_url, body)
case protocol
when :ostatus
process_atom(resource_url, body)
when :activitypub
ActivityPub::FetchRemoteAccountService.new.call(resource_url, body)
end
end
private

View File

@@ -1,6 +1,8 @@
# frozen_string_literal: true
class FetchRemoteResourceService < BaseService
include JsonLdHelper
attr_reader :url
def call(url)
@@ -14,11 +16,11 @@ class FetchRemoteResourceService < BaseService
private
def process_url
case xml_root
when 'feed'
FetchRemoteAccountService.new.call(atom_url, body)
when 'entry'
FetchRemoteStatusService.new.call(atom_url, body)
case type
when 'Person'
FetchRemoteAccountService.new.call(atom_url, body, protocol)
when 'Note'
FetchRemoteStatusService.new.call(atom_url, body, protocol)
end
end
@@ -31,7 +33,26 @@ class FetchRemoteResourceService < BaseService
end
def body
fetched_atom_feed.last
fetched_atom_feed.second
end
def protocol
fetched_atom_feed.third
end
def type
return json_data['type'] if protocol == :activitypub
case xml_root
when 'feed'
'Person'
when 'entry'
'Note'
end
end
def json_data
@_json_data ||= body_to_json(body)
end
def xml_root

View File

@@ -3,16 +3,20 @@
class FetchRemoteStatusService < BaseService
include AuthorExtractor
def call(url, prefetched_body = nil)
def call(url, prefetched_body = nil, protocol = :ostatus)
if prefetched_body.nil?
atom_url, body = FetchAtomService.new.call(url)
resource_url, body, protocol = FetchAtomService.new.call(url)
else
atom_url = url
body = prefetched_body
resource_url = url
body = prefetched_body
end
return nil if atom_url.nil?
process_atom(atom_url, body)
case protocol
when :ostatus
process_atom(resource_url, body)
when :activitypub
ActivityPub::FetchRemoteStatusService.new.call(resource_url, body)
end
end
private

View File

@@ -14,7 +14,7 @@ class FollowService < BaseService
return if source_account.following?(target_account)
if target_account.locked?
if target_account.locked? || target_account.activitypub?
request_follow(source_account, target_account)
else
direct_follow(source_account, target_account)
@@ -28,9 +28,11 @@ class FollowService < BaseService
if target_account.local?
NotifyService.new.call(target_account, follow_request)
else
elsif target_account.ostatus?
NotificationWorker.perform_async(build_follow_request_xml(follow_request), source_account.id, target_account.id)
AfterRemoteFollowRequestWorker.perform_async(follow_request.id)
elsif target_account.activitypub?
ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), source_account.id, target_account.inbox_url)
end
follow_request
@@ -63,4 +65,12 @@ class FollowService < BaseService
def build_follow_xml(follow)
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.follow_salmon(follow))
end
def build_json(follow_request)
Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
follow_request,
serializer: ActivityPub::FollowSerializer,
adapter: ActivityPub::Adapter
).as_json).sign!(follow_request.account))
end
end

View File

@@ -42,6 +42,8 @@ class PostStatusService < BaseService
# match both with and without U+FE0F (the emoji variation selector)
unless /👁\ufe0f?\z/.match?(status.content)
Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
ActivityPub::DistributionWorker.perform_async(status.id)
ActivityPub::ReplyDistributionWorker.perform_async(status.id) if status.reply? && status.thread.account.local?
end
if options[:idempotency].present?

View File

@@ -67,10 +67,13 @@ class ProcessInteractionService < BaseService
def follow!(account, target_account)
follow = account.follow!(target_account)
FollowRequest.find_by(account: account, target_account: target_account)&.destroy
NotifyService.new.call(target_account, follow)
end
def follow_request!(account, target_account)
return if account.requested?(target_account)
follow_request = FollowRequest.create!(account: account, target_account: target_account)
NotifyService.new.call(target_account, follow_request)
end
@@ -88,6 +91,7 @@ class ProcessInteractionService < BaseService
def unfollow!(account, target_account)
account.unfollow!(target_account)
FollowRequest.find_by(account: account, target_account: target_account)&.destroy
end
def reflect_block!(account, target_account)

View File

@@ -28,18 +28,32 @@ class ProcessMentionsService < BaseService
end
status.mentions.includes(:account).each do |mention|
mentioned_account = mention.account
if mentioned_account.local?
NotifyService.new.call(mentioned_account, mention)
else
NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id)
end
create_notification(status, mention)
end
end
private
def create_notification(status, mention)
mentioned_account = mention.account
if mentioned_account.local?
NotifyService.new.call(mentioned_account, mention)
elsif mentioned_account.ostatus? && (Rails.configuration.x.use_ostatus_privacy || !status.stream_entry.hidden?)
NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id)
elsif mentioned_account.activitypub? && !mentioned_account.following?(status.account)
ActivityPub::DeliveryWorker.perform_async(build_json(mention.status), mention.status.account_id, mentioned_account.inbox_url)
end
end
def build_json(status)
Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
status,
serializer: ActivityPub::ActivitySerializer,
adapter: ActivityPub::Adapter
).as_json).sign!(status.account))
end
def follow_remote_account_service
@follow_remote_account_service ||= ResolveRemoteAccountService.new
end

View File

@@ -20,17 +20,35 @@ class ReblogService < BaseService
reblog = account.statuses.create!(reblog: reblogged_status, text: '')
DistributionWorker.perform_async(reblog.id)
unless /👁$/.match?(reblogged_status.content)
Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id)
ActivityPub::DistributionWorker.perform_async(reblog.id)
end
if reblogged_status.local?
NotifyService.new.call(reblog.reblog.account, reblog)
else
NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), account.id, reblog.reblog.account_id)
end
create_notification(reblog)
reblog
end
private
def create_notification(reblog)
reblogged_status = reblog.reblog
if reblogged_status.account.local?
NotifyService.new.call(reblogged_status.account, reblog)
elsif reblogged_status.account.ostatus?
NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), reblog.account_id, reblogged_status.account_id)
elsif reblogged_status.account.activitypub? && !reblogged_status.account.following?(reblog.account)
ActivityPub::DeliveryWorker.perform_async(build_json(reblog), reblog.account_id, reblogged_status.account.inbox_url)
end
end
def build_json(reblog)
Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
reblog,
serializer: ActivityPub::ActivitySerializer,
adapter: ActivityPub::Adapter
).as_json).sign!(reblog.account))
end
end

View File

@@ -4,11 +4,28 @@ class RejectFollowService < BaseService
def call(source_account, target_account)
follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
follow_request.reject!
NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local?
create_notification(follow_request) unless source_account.local?
follow_request
end
private
def create_notification(follow_request)
if follow_request.account.ostatus?
NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id)
elsif follow_request.account.activitypub?
ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url)
end
end
def build_json(follow_request)
Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
follow_request,
serializer: ActivityPub::RejectFollowSerializer,
adapter: ActivityPub::Adapter
).as_json).sign!(follow_request.target_account))
end
def build_xml(follow_request)
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.reject_follow_request_salmon(follow_request))
end

View File

@@ -22,8 +22,8 @@ class RemoveStatusService < BaseService
return unless @account.local?
remove_from_mentioned(@stream_entry.reload)
Pubsubhubbub::DistributionWorker.perform_async(@stream_entry.id)
remove_from_remote_followers
remove_from_remote_affected
end
private
@@ -38,13 +38,50 @@ class RemoveStatusService < BaseService
end
end
def remove_from_mentioned(stream_entry)
salmon_xml = stream_entry_to_xml(stream_entry)
target_accounts = @mentions.map(&:account).reject(&:local?).uniq(&:domain)
def remove_from_remote_affected
# People who got mentioned in the status, or who
# reblogged it from someone else might not follow
# the author and wouldn't normally receive the
# delete notification - so here, we explicitly
# send it to them
NotificationWorker.push_bulk(target_accounts) do |target_account|
[salmon_xml, stream_entry.account_id, target_account.id]
target_accounts = (@mentions.map(&:account).reject(&:local?) + @reblogs.map(&:account).reject(&:local?)).uniq(&:id)
# Ostatus
NotificationWorker.push_bulk(target_accounts.select(&:ostatus?).uniq(&:domain)) do |target_account|
[salmon_xml, @account.id, target_account.id]
end
# ActivityPub
ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:inbox_url)) do |target_account|
[signed_activity_json, @account.id, target_account.inbox_url]
end
end
def remove_from_remote_followers
# OStatus
Pubsubhubbub::RawDistributionWorker.perform_async(salmon_xml, @account.id)
# ActivityPub
ActivityPub::DeliveryWorker.push_bulk(@account.followers.inboxes) do |inbox_url|
[signed_activity_json, @account.id, inbox_url]
end
end
def salmon_xml
@salmon_xml ||= stream_entry_to_xml(@stream_entry)
end
def signed_activity_json
@signed_activity_json ||= Oj.dump(ActivityPub::LinkedDataSignature.new(activity_json).sign!(@account))
end
def activity_json
@activity_json ||= ActiveModelSerializers::SerializableResource.new(
@status,
serializer: @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer,
adapter: ActivityPub::Adapter
).as_json
end
def remove_reblogs

View File

@@ -2,6 +2,7 @@
class ResolveRemoteAccountService < BaseService
include OStatus2::MagicKey
include JsonLdHelper
DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0'
@@ -12,6 +13,7 @@ class ResolveRemoteAccountService < BaseService
# @return [Account]
def call(uri, update_profile = true, redirected = nil)
@username, @domain = uri.split('@')
@update_profile = update_profile
return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
@@ -42,10 +44,11 @@ class ResolveRemoteAccountService < BaseService
if lock.acquired?
@account = Account.find_remote(@username, @domain)
create_account if @account.nil?
update_account
update_account_profile if update_profile
if activitypub_ready?
handle_activitypub
else
handle_ostatus
end
end
end
@@ -58,18 +61,46 @@ class ResolveRemoteAccountService < BaseService
private
def links_missing?
@webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? ||
!(activitypub_ready? || ostatus_ready?)
end
def ostatus_ready?
!(@webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? ||
@webfinger.link('salmon').nil? ||
@webfinger.link('http://webfinger.net/rel/profile-page').nil? ||
@webfinger.link('magic-public-key').nil? ||
canonical_uri.nil? ||
hub_url.nil?
hub_url.nil?)
end
def webfinger_update_due?
@account.nil? || @account.last_webfingered_at.nil? || @account.last_webfingered_at <= 1.day.ago
end
def activitypub_ready?
!@webfinger.link('self').nil? &&
['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type) &&
actor_json['inbox'].present?
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
return if actor_json.nil?
@account = ActivityPub::ProcessAccountService.new.call(@username, @domain, actor_json)
rescue Oj::ParseError
nil
end
def create_account
Rails.logger.debug "Creating new remote account for #{@username}@#{@domain}"
@@ -81,6 +112,7 @@ class ResolveRemoteAccountService < BaseService
def update_account
@account.last_webfingered_at = Time.now.utc
@account.protocol = :ostatus
@account.remote_url = atom_url
@account.salmon_url = salmon_url
@account.url = url
@@ -111,6 +143,10 @@ class ResolveRemoteAccountService < BaseService
@salmon_url ||= @webfinger.link('salmon').href
end
def actor_url
@actor_url ||= @webfinger.link('self').href
end
def url
@url ||= @webfinger.link('http://webfinger.net/rel/profile-page').href
end
@@ -149,6 +185,13 @@ class ResolveRemoteAccountService < BaseService
@atom_body = response.to_s
end
def actor_json
return @actor_json if defined?(@actor_json)
json = fetch_resource(actor_url)
@actor_json = supported_context?(json) && json['type'] == 'Person' ? json : nil
end
def atom
return @atom if defined?(@atom)
@atom = Nokogiri::XML(atom_body)

View File

@@ -2,7 +2,7 @@
class SubscribeService < BaseService
def call(account)
return unless account.ostatus?
return if account.hub_url.blank?
@account = account
@account.secret = SecureRandom.hex
@@ -42,7 +42,7 @@ class SubscribeService < BaseService
end
def some_local_account
@some_local_account ||= Account.local.first
@some_local_account ||= Account.local.where(suspended: false).first
end
# Any response in the 3xx or 4xx range, except for 429 (rate limit)

View File

@@ -5,11 +5,28 @@ class UnblockService < BaseService
return unless account.blocking?(target_account)
unblock = account.unblock!(target_account)
NotificationWorker.perform_async(build_xml(unblock), account.id, target_account.id) unless target_account.local?
create_notification(unblock) unless target_account.local?
unblock
end
private
def create_notification(unblock)
if unblock.target_account.ostatus?
NotificationWorker.perform_async(build_xml(unblock), unblock.account_id, unblock.target_account_id)
elsif unblock.target_account.activitypub?
ActivityPub::DeliveryWorker.perform_async(build_json(unblock), unblock.account_id, unblock.target_account.inbox_url)
end
end
def build_json(unblock)
Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
unblock,
serializer: ActivityPub::UndoBlockSerializer,
adapter: ActivityPub::Adapter
).as_json).sign!(unblock.account))
end
def build_xml(block)
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unblock_salmon(block))
end

View File

@@ -4,14 +4,30 @@ class UnfavouriteService < BaseService
def call(account, status)
favourite = Favourite.find_by!(account: account, status: status)
favourite.destroy!
NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id) unless status.local?
create_notification(favourite) unless status.local?
favourite
end
private
def create_notification(favourite)
status = favourite.status
if status.account.ostatus?
NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id)
elsif status.account.activitypub?
ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url)
end
end
def build_json(favourite)
Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
favourite,
serializer: ActivityPub::UndoLikeSerializer,
adapter: ActivityPub::Adapter
).as_json).sign!(favourite.account))
end
def build_xml(favourite)
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfavourite_salmon(favourite))
end

View File

@@ -5,14 +5,51 @@ class UnfollowService < BaseService
# @param [Account] source_account Where to unfollow from
# @param [Account] target_account Which to unfollow
def call(source_account, target_account)
follow = source_account.unfollow!(target_account)
return unless follow
NotificationWorker.perform_async(build_xml(follow), source_account.id, target_account.id) unless target_account.local?
UnmergeWorker.perform_async(target_account.id, source_account.id)
@source_account = source_account
@target_account = target_account
unfollow! || undo_follow_request!
end
private
def unfollow!
follow = Follow.find_by(account: @source_account, target_account: @target_account)
return unless follow
follow.destroy!
create_notification(follow) unless @target_account.local?
UnmergeWorker.perform_async(@target_account.id, @source_account.id)
follow
end
def undo_follow_request!
follow_request = FollowRequest.find_by(account: @source_account, target_account: @target_account)
return unless follow_request
follow_request.destroy!
create_notification(follow_request) unless @target_account.local?
follow_request
end
def create_notification(follow)
if follow.target_account.ostatus?
NotificationWorker.perform_async(build_xml(follow), follow.account_id, follow.target_account_id)
elsif follow.target_account.activitypub?
ActivityPub::DeliveryWorker.perform_async(build_json(follow), follow.account_id, follow.target_account.inbox_url)
end
end
def build_json(follow)
Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
follow,
serializer: ActivityPub::UndoFollowSerializer,
adapter: ActivityPub::Adapter
).as_json).sign!(follow.account))
end
def build_xml(follow)
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfollow_salmon(follow))
end

View File

@@ -2,7 +2,7 @@
class UnsubscribeService < BaseService
def call(account)
return unless account.ostatus?
return if account.hub_url.blank?
@account = account
@response = build_request.perform

View File

@@ -0,0 +1,21 @@
# frozen_string_literal: true
class UpdateAccountService < BaseService
def call(account, params, raise_error: false)
was_locked = account.locked
update_method = raise_error ? :update! : :update
account.send(update_method, params).tap do |ret|
next unless ret
authorize_all_follow_requests(account) if was_locked && !account.locked
end
end
private
def authorize_all_follow_requests(account)
follow_requests = FollowRequest.where(target_account: account)
AuthorizeFollowWorker.push_bulk(follow_requests) do |req|
[req.account_id, req.target_account_id]
end
end
end