nyastodon/app/services/activitypub/process_account_service.rb
Claire 1145dbd327
Improve error reporting and logging when processing remote accounts (#15605)
* Add a more descriptive PrivateNetworkAddressError exception class

* Remove unnecessary exception class to rescue clause

* Remove unnecessary include to JsonLdHelper

* Give more neutral error message when too many webfinger redirects

* Remove unnecessary guard condition

* Rework how “ActivityPub::FetchRemoteAccountService” handles errors

Add “suppress_errors” keyword argument to avoid raising errors in
ActivityPub::FetchRemoteAccountService#call (default/previous behavior).

* Rework how “ActivityPub::FetchRemoteKeyService” handles errors

Add “suppress_errors” keyword argument to avoid raising errors in
ActivityPub::FetchRemoteKeyService#call (default/previous behavior).

* Fix Webfinger::RedirectError not being a subclass of Webfinger::Error

* Add suppress_errors option to ResolveAccountService

Defaults to true (to preserve previous behavior). If set to false,
errors will be raised instead of caught, allowing the caller to be
informed of what went wrong.

* Return more precise error when failing to fetch account signing AP payloads

* Add tests

* Fixes

* Refactor error handling a bit

* Fix various issues

* Add specific error when provided Digest is not 256 bits of base64-encoded data

* Please CodeClimate

* Improve webfinger error reporting
2022-09-20 23:30:26 +02:00

313 lines
9.7 KiB
Ruby

# frozen_string_literal: true
class ActivityPub::ProcessAccountService < BaseService
include JsonLdHelper
include DomainControlHelper
include Redisable
include Lockable
# Should be called with confirmed valid JSON
# and WebFinger-resolved username and domain
def call(username, domain, json, options = {})
return if json['inbox'].blank? || unsupported_uri_scheme?(json['id']) || domain_not_allowed?(domain)
@options = options
@json = json
@uri = @json['id']
@username = username
@domain = domain
@collections = {}
with_lock("process_account:#{@uri}") do
@account = Account.remote.find_by(uri: @uri) if @options[:only_key]
@account ||= Account.find_remote(@username, @domain)
@old_public_key = @account&.public_key
@old_protocol = @account&.protocol
@suspension_changed = false
create_account if @account.nil?
update_account
process_tags
process_duplicate_accounts! if @options[:verified_webfinger]
end
after_protocol_change! if protocol_changed?
after_key_change! if key_changed? && !@options[:signed_with_known_key]
clear_tombstones! if key_changed?
after_suspension_change! if suspension_changed?
unless @options[:only_key] || @account.suspended?
check_featured_collection! if @account.featured_collection_url.present?
check_links! unless @account.fields.empty?
end
@account
rescue Oj::ParseError
nil
end
private
def create_account
@account = Account.new
@account.protocol = :activitypub
@account.username = @username
@account.domain = @domain
@account.private_key = nil
@account.suspended_at = domain_block.created_at if auto_suspend?
@account.suspension_origin = :local if auto_suspend?
@account.silenced_at = domain_block.created_at if auto_silence?
@account.save
end
def update_account
@account.last_webfingered_at = Time.now.utc unless @options[:only_key]
@account.protocol = :activitypub
set_suspension!
set_immediate_protocol_attributes!
set_fetchable_key! unless @account.suspended? && @account.suspension_origin_local?
set_immediate_attributes! unless @account.suspended?
set_fetchable_attributes! unless @options[:only_key] || @account.suspended?
@account.save_with_optional_media!
end
def set_immediate_protocol_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.uri = @uri
@account.actor_type = actor_type
@account.created_at = @json['published'] if @json['published'].present?
end
def set_immediate_attributes!
@account.featured_collection_url = @json['featured'] || ''
@account.devices_url = @json['devices'] || ''
@account.display_name = @json['name'] || ''
@account.note = @json['summary'] || ''
@account.locked = @json['manuallyApprovesFollowers'] || false
@account.fields = property_values || {}
@account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) }
@account.discoverable = @json['discoverable'] || false
end
def set_fetchable_key!
@account.public_key = public_key || ''
end
def set_fetchable_attributes!
begin
@account.avatar_remote_url = image_url('icon') || '' unless skip_download?
@account.avatar = nil if @account.avatar_remote_url.blank?
rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
RedownloadAvatarWorker.perform_in(rand(30..600).seconds, @account.id)
end
begin
@account.header_remote_url = image_url('image') || '' unless skip_download?
@account.header = nil if @account.header_remote_url.blank?
rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
RedownloadHeaderWorker.perform_in(rand(30..600).seconds, @account.id)
end
@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?
@account.hide_collections = following_private? || followers_private?
@account.moved_to_account = @json['movedTo'].present? ? moved_account : nil
end
def set_suspension!
return if @account.suspended? && @account.suspension_origin_local?
if @account.suspended? && !@json['suspended']
@account.unsuspend!
@suspension_changed = true
elsif !@account.suspended? && @json['suspended']
@account.suspend!(origin: :remote)
@suspension_changed = true
end
end
def after_protocol_change!
ActivityPub::PostUpgradeWorker.perform_async(@account.domain)
end
def after_key_change!
RefollowWorker.perform_async(@account.id)
end
def after_suspension_change!
if @account.suspended?
Admin::SuspensionWorker.perform_async(@account.id)
else
Admin::UnsuspensionWorker.perform_async(@account.id)
end
end
def check_featured_collection!
ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id)
end
def check_links!
VerifyAccountLinksWorker.perform_async(@account.id)
end
def process_duplicate_accounts!
return unless Account.where(uri: @account.uri).where.not(id: @account.id).exists?
AccountMergingWorker.perform_async(@account.id)
end
def actor_type
if @json['type'].is_a?(Array)
@json['type'].find { |type| ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES.include?(type) }
else
@json['type']
end
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_without_id_validation(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_without_id_validation(value)
key['publicKeyPem'] if key
end
def url
return if @json['url'].blank?
url_candidate = url_to_href(@json['url'], 'text/html')
if unsupported_uri_scheme?(url_candidate) || mismatching_origin?(url_candidate)
nil
else
url_candidate
end
end
def property_values
return unless @json['attachment'].is_a?(Array)
as_array(@json['attachment']).select { |attachment| attachment['type'] == 'PropertyValue' }.map { |attachment| attachment.slice('name', 'value') }
end
def mismatching_origin?(url)
needle = Addressable::URI.parse(url).host
haystack = Addressable::URI.parse(@uri).host
!haystack.casecmp(needle).zero?
end
def outbox_total_items
collection_info('outbox').first
end
def following_total_items
collection_info('following').first
end
def followers_total_items
collection_info('followers').first
end
def following_private?
!collection_info('following').last
end
def followers_private?
!collection_info('followers').last
end
def collection_info(type)
return [nil, nil] if @json[type].blank?
return @collections[type] if @collections.key?(type)
collection = fetch_resource_without_id_validation(@json[type])
total_items = collection.is_a?(Hash) && collection['totalItems'].present? && collection['totalItems'].is_a?(Numeric) ? collection['totalItems'] : nil
has_first_page = collection.is_a?(Hash) && collection['first'].present?
@collections[type] = [total_items, has_first_page]
rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::LengthValidationError
@collections[type] = [nil, nil]
end
def moved_account
account = ActivityPub::TagManager.instance.uri_to_resource(@json['movedTo'], Account)
account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], id: true, break_on_redirect: true)
account
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.rule_for(@domain)
end
def key_changed?
!@old_public_key.nil? && @old_public_key != @account.public_key
end
def suspension_changed?
@suspension_changed
end
def clear_tombstones!
Tombstone.where(account_id: @account.id).delete_all
end
def protocol_changed?
!@old_protocol.nil? && @old_protocol != @account.protocol
end
def process_tags
return if @json['tag'].blank?
as_array(@json['tag']).each do |tag|
process_emoji tag if equals_or_includes?(tag['type'], 'Emoji')
end
end
def process_emoji(tag)
return if skip_download?
return if tag['name'].blank? || tag['icon'].blank? || tag['icon']['url'].blank?
shortcode = tag['name'].delete(':')
image_url = tag['icon']['url']
uri = tag['id']
updated = tag['updated']
emoji = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain)
return unless emoji.nil? || image_url != emoji.image_remote_url || (updated && updated >= emoji.updated_at)
emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: shortcode, uri: uri)
emoji.image_remote_url = image_url
emoji.save
end
end