diff options
Diffstat (limited to 'app/services')
35 files changed, 1044 insertions, 181 deletions
diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb new file mode 100644 index 000000000..3eeca585e --- /dev/null +++ b/app/services/activitypub/fetch_remote_account_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class ActivityPub::FetchRemoteAccountService < BaseService + include JsonLdHelper + + # Should be called when uri has already been checked for locality + # Does a WebFinger roundtrip on each call + def call(uri, 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 diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb new file mode 100644 index 000000000..ebd64071e --- /dev/null +++ b/app/services/activitypub/fetch_remote_key_service.rb @@ -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 diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb new file mode 100644 index 000000000..a95931afe --- /dev/null +++ b/app/services/activitypub/fetch_remote_status_service.rb @@ -0,0 +1,50 @@ +# 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? + + return if actor.suspended? + + 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 diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb new file mode 100644 index 000000000..811209537 --- /dev/null +++ b/app/services/activitypub/process_account_service.rb @@ -0,0 +1,168 @@ +# 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 if json['inbox'].blank? + + @json = json + @uri = @json['id'] + @username = username + @domain = domain + @collections = {} + + RedisLock.acquire(lock_options) do |lock| + if lock.acquired? + @account = Account.find_by(uri: @uri) + @old_public_key = @account&.public_key + @old_protocol = @account&.protocol + + create_account if @account.nil? + update_account + end + end + + after_protocol_change! if protocol_changed? + after_key_change! if key_changed? + + @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 + end + + def update_account + @account.last_webfingered_at = Time.now.utc + @account.protocol = :activitypub + + set_immediate_attributes! + set_fetchable_attributes! + + @account.save_with_optional_media! + end + + def set_immediate_attributes! + @account.inbox_url = @json['inbox'] || '' + @account.outbox_url = @json['outbox'] || '' + @account.shared_inbox_url = (@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) || '' + @account.followers_url = @json['followers'] || '' + @account.url = url || @uri + @account.display_name = @json['name'] || '' + @account.note = @json['summary'] || '' + @account.locked = @json['manuallyApprovesFollowers'] || false + end + + def set_fetchable_attributes! + @account.avatar_remote_url = image_url('icon') unless skip_download? + @account.header_remote_url = image_url('image') unless skip_download? + @account.public_key = public_key || '' + @account.statuses_count = outbox_total_items if outbox_total_items.present? + @account.following_count = following_total_items if following_total_items.present? + @account.followers_count = followers_total_items if followers_total_items.present? + end + + def after_protocol_change! + ActivityPub::PostUpgradeWorker.perform_async(@account.domain) + end + + def after_key_change! + RefollowWorker.perform_async(@account.id) + end + + def image_url(key) + value = first_of_value(@json[key]) + + return if value.nil? + return value['url'] if value.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 url + return if @json['url'].blank? + + value = first_of_value(@json['url']) + + return value if value.is_a?(String) + + value['href'] + end + + def outbox_total_items + collection_total_items('outbox') + end + + def following_total_items + collection_total_items('following') + end + + def followers_total_items + collection_total_items('followers') + end + + def collection_total_items(type) + return if @json[type].blank? + return @collections[type] if @collections.key?(type) + + collection = fetch_resource(@json[type]) + + @collections[type] = collection.is_a?(Hash) && collection['totalItems'].present? && collection['totalItems'].is_a?(Numeric) ? collection['totalItems'] : nil + rescue HTTP::Error, OpenSSL::SSL::SSLError + @collections[type] = nil + end + + def skip_download? + @account.suspended? || domain_block&.reject_media? + end + + def auto_suspend? + domain_block&.suspend? + end + + def auto_silence? + domain_block&.silence? + end + + def domain_block + return @domain_block if defined?(@domain_block) + @domain_block = DomainBlock.find_by(domain: @domain) + end + + def key_changed? + !@old_public_key.nil? && @old_public_key != @account.public_key + end + + def protocol_changed? + !@old_protocol.nil? && @old_protocol != @account.protocol + end + + def lock_options + { redis: Redis.current, key: "process_account:#{@uri}" } + end +end diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb new file mode 100644 index 000000000..59cb65c65 --- /dev/null +++ b/app/services/activitypub/process_collection_service.rb @@ -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 unless supported_context? + return if different_actor? && verify_account!.nil? + return if @account.suspended? || @account.local? + + 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 diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb index 41815a393..b1bff8962 100644 --- a/app/services/authorize_follow_service.rb +++ b/app/services/authorize_follow_service.rb @@ -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 diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index ab810c628..2fd623922 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -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_s)] }.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 + + 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) - stream_entry_ids.each_slice(100) do |batch_of_stream_entry_ids| - @stream_entry_batches << [batch_of_stream_entry_ids] + other_recipients.each do |target_account| + @activity_json_batches << [build_json(status), account.id, target_account.inbox_url] + end end end @@ -62,6 +84,8 @@ class BatchedRemoveStatusService < BaseService end def unpush_from_public_timelines(status) + return unless status.public_visibility? + payload = @json_payloads[status.id] redis.pipelined do @@ -78,11 +102,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 +134,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 diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb index a6b3c4cdb..eefdc0dbf 100644 --- a/app/services/block_domain_service.rb +++ b/app/services/block_domain_service.rb @@ -26,11 +26,12 @@ class BlockDomainService < BaseService def clear_media! clear_account_images clear_account_attachments + clear_emojos end 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 @@ -51,6 +52,10 @@ class BlockDomainService < BaseService end end + def clear_emojos + emojis_from_blocked_domains.destroy_all + end + def blocked_domain domain_block.domain end @@ -62,4 +67,8 @@ class BlockDomainService < BaseService def media_from_blocked_domain MediaAttachment.joins(:account).merge(blocked_domain_accounts).reorder(nil) end + + def emojis_from_blocked_domains + CustomEmoji.where(domain: blocked_domain) + end end diff --git a/app/services/block_service.rb b/app/services/block_service.rb index 5d7bf6a3b..b39c3eef2 100644 --- a/app/services/block_service.rb +++ b/app/services/block_service.rb @@ -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 diff --git a/app/services/bootstrap_timeline_service.rb b/app/services/bootstrap_timeline_service.rb new file mode 100644 index 000000000..c01e25824 --- /dev/null +++ b/app/services/bootstrap_timeline_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class BootstrapTimelineService < BaseService + def call(source_account) + bootstrap_timeline_accounts.each do |target_account| + FollowService.new.call(source_account, target_account) + end + end + + private + + def bootstrap_timeline_accounts + return @bootstrap_timeline_accounts if defined?(@bootstrap_timeline_accounts) + + @bootstrap_timeline_accounts = bootstrap_timeline_accounts_usernames.empty? ? admin_accounts : local_unlocked_accounts(bootstrap_timeline_accounts_usernames) + end + + def bootstrap_timeline_accounts_usernames + @bootstrap_timeline_accounts_usernames ||= (Setting.bootstrap_timeline_accounts || '').split(',').map { |str| str.strip.gsub(/\A@/, '') }.reject(&:blank?) + end + + def admin_accounts + User.admins + .includes(:account) + .where(accounts: { locked: false }) + .map(&:account) + end + + def local_unlocked_accounts(usernames) + Account.local + .where(username: usernames) + .where(locked: false) + end +end diff --git a/app/services/concerns/author_extractor.rb b/app/services/concerns/author_extractor.rb index 867d6dc25..c2366188a 100644 --- a/app/services/concerns/author_extractor.rb +++ b/app/services/concerns/author_extractor.rb @@ -5,12 +5,12 @@ module AuthorExtractor return nil if xml.nil? # Try <email> for acct - acct = xml.at_xpath('./xmlns:author/xmlns:email', xmlns: TagManager::XMLNS)&.content + acct = xml.at_xpath('./xmlns:author/xmlns:email', xmlns: OStatus::TagManager::XMLNS)&.content # Try <name> + <uri> if acct.blank? - username = xml.at_xpath('./xmlns:author/xmlns:name', xmlns: TagManager::XMLNS)&.content - uri = xml.at_xpath('./xmlns:author/xmlns:uri', xmlns: TagManager::XMLNS)&.content + username = xml.at_xpath('./xmlns:author/xmlns:name', xmlns: OStatus::TagManager::XMLNS)&.content + uri = xml.at_xpath('./xmlns:author/xmlns:uri', xmlns: OStatus::TagManager::XMLNS)&.content return nil if username.blank? || uri.blank? diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index 291f9e56e..44df3ed13 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -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 diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb index 3ac441e3e..9c5777b5d 100644 --- a/app/services/fetch_atom_service.rb +++ b/app/services/fetch_atom_service.rb @@ -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 - - Rails.logger.debug "Remote status HEAD request returned code #{response.code}" + result = process(url) - response = Request.new(:get, url).perform if response.code == 405 + # retry without ActivityPub + result ||= process(url) if @unsupported_activity - 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' + def process(url, terminal = false) + @url = url + perform_request + process_response(terminal) + end + + def perform_request + accept = 'text/html' + accept = 'application/activity+json, application/ld+json, application/atom+xml, ' + accept unless @unsupported_activity + + @response = Request.new(:get, @url) + .add_headers('Accept' => accept) + .perform + end - page = Nokogiri::HTML(body) - alternate_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' } + def process_response(terminal = false) + return nil if @response.code != 200 - return nil if alternate_link.nil? - [alternate_link['href'], fetch(alternate_link['href'])] + 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_headers(url, response) - Rails.logger.debug 'Processing link header' + 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)) - 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)) + result ||= process(json_link.href, terminal: true) unless json_link.nil? || @unsupported_activity + result ||= process(atom_link.href, terminal: true) unless atom_link.nil? - return process_html(fetch(url)) if alternate_link.nil? - [alternate_link.href, fetch(alternate_link.href)] + result end - def fetch(url) - Request.new(:get, url).perform.to_s + def supported_activity?(body) + json = body_to_json(body) + return false unless supported_context?(json) + json['type'] == 'Person' ? json['inbox'].present? : true end end diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index 20c85e0ea..4acbfae7a 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -1,32 +1,56 @@ # frozen_string_literal: true class FetchLinkCardService < BaseService - URL_PATTERN = %r{https?://\S+} + URL_PATTERN = %r{ + ( # $1 URL + (https?:\/\/)? # $2 Protocol (optional) + (#{Twitter::Regex[:valid_domain]}) # $3 Domain(s) + (?::(#{Twitter::Regex[:valid_port_number]}))? # $4 Port number (optional) + (/#{Twitter::Regex[:valid_url_path]}*)? # $5 URL Path and anchor + (\?#{Twitter::Regex[:valid_url_query_chars]}*#{Twitter::Regex[:valid_url_query_ending_chars]})? # $6 Query String + ) + }iox 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? || @card.updated_at <= 2.weeks.ago + end + end - attempt_opengraph(card, url) unless attempt_oembed(card, url) + attach_card if @card&.persisted? 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.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[0]).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 +68,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 +112,36 @@ 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)) + + if meta_property(page, 'twitter:player') + @card.type = :video + @card.width = meta_property(page, 'twitter:player:width') || 0 + @card.height = meta_property(page, 'twitter:player:height') || 0 + @card.html = content_tag(:iframe, nil, src: meta_property(page, 'twitter:player'), + width: @card.width, + height: @card.height, + allowtransparency: 'true', + scrolling: 'no', + frameborder: '0') + else + @card.type = :link + @card.image_remote_url = meta_property(page, 'og:image') if meta_property(page, 'og:image') + end - 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.title = meta_property(page, 'og:title').presence || page.at_xpath('//title')&.content || '' + @card.description = meta_property(page, 'og:description').presence || meta_property(page, 'description') || '' - return if card.title.blank? + return if @card.title.blank? && @card.html.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 diff --git a/app/services/fetch_remote_account_service.rb b/app/services/fetch_remote_account_service.rb index 8eed0d454..bd98e70d1 100644 --- a/app/services/fetch_remote_account_service.rb +++ b/app/services/fetch_remote_account_service.rb @@ -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 @@ -21,7 +25,7 @@ class FetchRemoteAccountService < BaseService xml = Nokogiri::XML(body) xml.encoding = 'utf-8' - account = author_from_xml(xml.at_xpath('/xmlns:feed', xmlns: TagManager::XMLNS), false) + account = author_from_xml(xml.at_xpath('/xmlns:feed', xmlns: OStatus::TagManager::XMLNS), false) UpdateRemoteProfileService.new.call(xml, account) unless account.nil? diff --git a/app/services/fetch_remote_resource_service.rb b/app/services/fetch_remote_resource_service.rb index 2c1c1f05f..341664272 100644 --- a/app/services/fetch_remote_resource_service.rb +++ b/app/services/fetch_remote_resource_service.rb @@ -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 diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb index b9f5f97b1..1b90854c4 100644 --- a/app/services/fetch_remote_status_service.rb +++ b/app/services/fetch_remote_status_service.rb @@ -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 @@ -23,7 +27,7 @@ class FetchRemoteStatusService < BaseService xml = Nokogiri::XML(body) xml.encoding = 'utf-8' - account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: TagManager::XMLNS)) + account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: OStatus::TagManager::XMLNS)) domain = Addressable::URI.parse(url).normalized_host return nil unless !account.nil? && confirmed_domain?(domain, account) diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 3155feaa4..791773f25 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -5,16 +5,16 @@ class FollowService < BaseService # Follow a remote user, notify remote user about the follow # @param [Account] source_account From which to follow - # @param [String] uri User URI to follow in the form of username@domain + # @param [String, Account] uri User URI to follow in the form of username@domain (or account record) def call(source_account, uri) - target_account = ResolveRemoteAccountService.new.call(uri) + target_account = uri.is_a?(Account) ? uri : ResolveRemoteAccountService.new.call(uri) raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended? raise Mastodon::NotPermittedError if target_account.blocking?(source_account) || source_account.blocking?(target_account) - return if source_account.following?(target_account) + return if source_account.following?(target_account) || source_account.requested?(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 diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb index 92f92cc7d..56cbebd5d 100644 --- a/app/services/mute_service.rb +++ b/app/services/mute_service.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true class MuteService < BaseService - def call(account, target_account) + def call(account, target_account, notifications: nil) return if account.id == target_account.id FeedManager.instance.clear_from_timeline(account, target_account) - account.mute!(target_account) + account.mute!(target_account, notifications: notifications) end end diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index ca53c61c5..fb09df983 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -41,7 +41,7 @@ class NotifyService < BaseService blocked ||= @recipient.id == @notification.from_account.id # Skip for interactions with self blocked ||= @recipient.domain_blocking?(@notification.from_account.domain) && !@recipient.following?(@notification.from_account) # Skip for domain blocked accounts blocked ||= @recipient.blocking?(@notification.from_account) # Skip for blocked accounts - blocked ||= @recipient.muting?(@notification.from_account) # Skip for muted accounts + blocked ||= @recipient.muting_notifications?(@notification.from_account) blocked ||= (@notification.from_account.silenced? && !@recipient.following?(@notification.from_account)) # Hellban blocked ||= (@recipient.user.settings.interactions['must_be_follower'] && !@notification.from_account.following?(@recipient)) # Options blocked ||= (@recipient.user.settings.interactions['must_be_following'] && !@recipient.following?(@notification.from_account)) # Options diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 0ecd8a9cd..d1b8f42c7 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -27,9 +27,10 @@ class PostStatusService < BaseService thread: in_reply_to, sensitive: options[:sensitive], spoiler_text: options[:spoiler_text] || '', - visibility: options[:visibility], - language: detect_language_for(text, account), + visibility: options[:visibility] || account.user&.setting_default_privacy, + language: LanguageDetector.instance.detect(text, account), application: options[:application]) + attach_media(status, media) end @@ -42,6 +43,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? @@ -70,10 +73,6 @@ class PostStatusService < BaseService media.update(status_id: status.id) end - def detect_language_for(text, account) - LanguageDetector.new(text, account).to_iso_s - end - def process_mentions_service @process_mentions_service ||= ProcessMentionsService.new end diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb index 31191a818..2a5f1e2bc 100644 --- a/app/services/process_feed_service.rb +++ b/app/services/process_feed_service.rb @@ -16,7 +16,7 @@ class ProcessFeedService < BaseService end def process_entries(xml, account) - xml.xpath('//xmlns:entry', xmlns: TagManager::XMLNS).reverse_each.map { |entry| process_entry(entry, account) }.compact + xml.xpath('//xmlns:entry', xmlns: OStatus::TagManager::XMLNS).reverse_each.map { |entry| process_entry(entry, account) }.compact end def process_entry(xml, account) diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb index cc99cde03..1fca3832b 100644 --- a/app/services/process_interaction_service.rb +++ b/app/services/process_interaction_service.rb @@ -13,7 +13,7 @@ class ProcessInteractionService < BaseService xml = Nokogiri::XML(body) xml.encoding = 'utf-8' - account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: TagManager::XMLNS)) + account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: OStatus::TagManager::XMLNS)) return if account.nil? || account.suspended? @@ -54,23 +54,26 @@ class ProcessInteractionService < BaseService private def mentions_account?(xml, account) - xml.xpath('/xmlns:entry/xmlns:link[@rel="mentioned"]', xmlns: TagManager::XMLNS).each { |mention_link| return true if [TagManager.instance.uri_for(account), TagManager.instance.url_for(account)].include?(mention_link.attribute('href').value) } + xml.xpath('/xmlns:entry/xmlns:link[@rel="mentioned"]', xmlns: OStatus::TagManager::XMLNS).each { |mention_link| return true if [OStatus::TagManager.instance.uri_for(account), OStatus::TagManager.instance.url_for(account)].include?(mention_link.attribute('href').value) } false end def verb(xml) - raw = xml.at_xpath('//activity:verb', activity: TagManager::AS_XMLNS).content - TagManager::VERBS.key(raw) + raw = xml.at_xpath('//activity:verb', activity: OStatus::TagManager::AS_XMLNS).content + OStatus::TagManager::VERBS.key(raw) rescue :post end 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) @@ -100,7 +104,7 @@ class ProcessInteractionService < BaseService end def delete_post!(xml, account) - status = Status.find(xml.at_xpath('//xmlns:id', xmlns: TagManager::XMLNS).content) + status = Status.find(xml.at_xpath('//xmlns:id', xmlns: OStatus::TagManager::XMLNS).content) return if status.nil? @@ -133,12 +137,12 @@ class ProcessInteractionService < BaseService def status(xml) uri = activity_id(xml) - return nil unless TagManager.instance.local_id?(uri) - Status.find(TagManager.instance.unique_tag_to_local_id(uri, 'Status')) + return nil unless OStatus::TagManager.instance.local_id?(uri) + Status.find(OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Status')) end def activity_id(xml) - xml.at_xpath('//activity:object', activity: TagManager::AS_XMLNS).at_xpath('./xmlns:id', xmlns: TagManager::XMLNS).content + xml.at_xpath('//activity:object', activity: OStatus::TagManager::AS_XMLNS).at_xpath('./xmlns:id', xmlns: OStatus::TagManager::XMLNS).content end def salmon diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 438033d22..1c3eea369 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -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? && !status.stream_entry.hidden? + NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id) + elsif mentioned_account.activitypub? + 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 diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 497cdb4f5..52e3ba0e0 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -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 + create_notification(reblog) + reblog + end + + private - 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) + 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 - reblog + 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 diff --git a/app/services/reject_follow_service.rb b/app/services/reject_follow_service.rb index fd7e66c23..c1f7bcb60 100644 --- a/app/services/reject_follow_service.rb +++ b/app/services/reject_follow_service.rb @@ -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 diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index a5281f586..14f24908c 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -4,7 +4,7 @@ class RemoveStatusService < BaseService include StreamEntryRenderer def call(status) - @payload = Oj.dump(event: :delete, payload: status.id) + @payload = Oj.dump(event: :delete, payload: status.id.to_s) @status = status @account = status.account @tags = status.tags.pluck(:name).to_a @@ -14,6 +14,7 @@ class RemoveStatusService < BaseService remove_from_self if status.account.local? remove_from_followers + remove_from_affected remove_reblogs remove_from_hashtags remove_from_public @@ -22,8 +23,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,15 +39,58 @@ 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_affected + @mentions.map(&:account).select(&:local?).each do |account| + Redis.current.publish("timeline:#{account.id}", @payload) + end + end + + 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 + + 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 - NotificationWorker.push_bulk(target_accounts) do |target_account| - [salmon_xml, stream_entry.account_id, target_account.id] + 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 # We delete reblogs of the status before the original status, # because once original status is gone, reblogs will disappear @@ -68,6 +112,8 @@ class RemoveStatusService < BaseService end def remove_from_hashtags + return unless @status.public_visibility? + @tags.each do |hashtag| Redis.current.publish("timeline:hashtag:#{hashtag}", @payload) Redis.current.publish("timeline:hashtag:#{hashtag}:local", @payload) if @status.local? @@ -75,6 +121,8 @@ class RemoveStatusService < BaseService end def remove_from_public + return unless @status.public_visibility? + Redis.current.publish('timeline:public', @payload) Redis.current.publish('timeline:public:local', @payload) if @status.local? end diff --git a/app/services/resolve_remote_account_service.rb b/app/services/resolve_remote_account_service.rb index e0e2ebc83..57c80fc82 100644 --- a/app/services/resolve_remote_account_service.rb +++ b/app/services/resolve_remote_account_service.rb @@ -2,6 +2,7 @@ class ResolveRemoteAccountService < BaseService include OStatus2::MagicKey + include JsonLdHelper DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0' @@ -12,6 +13,7 @@ class ResolveRemoteAccountService < BaseService # @return [Account] def call(uri, update_profile = true, redirected = nil) @username, @domain = uri.split('@') + @update_profile = update_profile return Account.find_local(@username) if TagManager.instance.local_domain?(@domain) @@ -42,10 +44,11 @@ class ResolveRemoteAccountService < BaseService if lock.acquired? @account = Account.find_remote(@username, @domain) - create_account if @account.nil? - update_account - - update_account_profile if update_profile + if activitypub_ready? + handle_activitypub + else + handle_ostatus + end end end @@ -58,18 +61,47 @@ class ResolveRemoteAccountService < BaseService private def links_missing? - @webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? || + !(activitypub_ready? || ostatus_ready?) + end + + def ostatus_ready? + !(@webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? || @webfinger.link('salmon').nil? || @webfinger.link('http://webfinger.net/rel/profile-page').nil? || @webfinger.link('magic-public-key').nil? || canonical_uri.nil? || - hub_url.nil? + hub_url.nil?) end def webfinger_update_due? @account.nil? || @account.last_webfingered_at.nil? || @account.last_webfingered_at <= 1.day.ago end + def activitypub_ready? + !@webfinger.link('self').nil? && + ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type) && + !actor_json.nil? && + 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 +113,7 @@ class ResolveRemoteAccountService < BaseService def update_account @account.last_webfingered_at = Time.now.utc + @account.protocol = :ostatus @account.remote_url = atom_url @account.salmon_url = salmon_url @account.url = url @@ -111,6 +144,10 @@ class ResolveRemoteAccountService < BaseService @salmon_url ||= @webfinger.link('salmon').href end + def actor_url + @actor_url ||= @webfinger.link('self').href + end + def url @url ||= @webfinger.link('http://webfinger.net/rel/profile-page').href end @@ -149,6 +186,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) diff --git a/app/services/subscribe_service.rb b/app/services/subscribe_service.rb index d3e41e691..bfa7ff8c8 100644 --- a/app/services/subscribe_service.rb +++ b/app/services/subscribe_service.rb @@ -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) diff --git a/app/services/unblock_service.rb b/app/services/unblock_service.rb index ff15c7275..869f62d1c 100644 --- a/app/services/unblock_service.rb +++ b/app/services/unblock_service.rb @@ -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 diff --git a/app/services/unfavourite_service.rb b/app/services/unfavourite_service.rb index 564aaee46..2fda11bd6 100644 --- a/app/services/unfavourite_service.rb +++ b/app/services/unfavourite_service.rb @@ -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 diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index 388909586..73a64929f 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -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 diff --git a/app/services/unsubscribe_service.rb b/app/services/unsubscribe_service.rb index c5e0e73fe..b99046712 100644 --- a/app/services/unsubscribe_service.rb +++ b/app/services/unsubscribe_service.rb @@ -2,18 +2,21 @@ class UnsubscribeService < BaseService def call(account) - return unless account.ostatus? + return if account.hub_url.blank? - @account = account - @response = build_request.perform + @account = account - Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{@response.status}" unless @response.status.success? + begin + @response = build_request.perform + + Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{@response.status}" unless @response.status.success? + rescue HTTP::Error, OpenSSL::SSL::SSLError => e + Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{e}" + end @account.secret = '' @account.subscription_expires_at = nil @account.save! - rescue HTTP::Error, OpenSSL::SSL::SSLError - Rails.logger.debug "PuSH subscription request for #{@account.acct} could not be made due to HTTP or SSL error" end private diff --git a/app/services/update_account_service.rb b/app/services/update_account_service.rb new file mode 100644 index 000000000..09ea377e7 --- /dev/null +++ b/app/services/update_account_service.rb @@ -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 diff --git a/app/services/verify_salmon_service.rb b/app/services/verify_salmon_service.rb index cd674837d..205b35d8b 100644 --- a/app/services/verify_salmon_service.rb +++ b/app/services/verify_salmon_service.rb @@ -9,7 +9,7 @@ class VerifySalmonService < BaseService xml = Nokogiri::XML(body) xml.encoding = 'utf-8' - account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: TagManager::XMLNS)) + account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: OStatus::TagManager::XMLNS)) if account.nil? false |