catstodon/app/services/resolve_remote_account_service.rb
ThibG f29918e707 [WiP] Whenever a remote keypair changes, unfollow them and re-subscribe to … (#4907)
* Whenever a remote keypair changes, unfollow them and re-subscribe to them

In Mastodon (it could be different for other OStatus or AP-enabled software),
a keypair change is indicative of whole user (or instance) data loss. In this
situation, the “new” user might be different, and almost certainly has an empty
followers list. In this case, Mastodon instances will disagree on follower
lists, leading to unreliable delivery and “shadow followers”, that is users
believed by a remote instance to be followers, without the affected user
knowing.

Drawbacks of this change are:
1. If an user legitimately changes public key for some reason without losing
   data (not possible in Mastodon at the moment), they will have their remote
   followers unsubscribed/re-subscribed needlessly.
2. Depending of the number of remote followers, this may generate quite some
   traffic.
3. If the user change is an attempt at usurpation, the remote followers will
   unknowingly follow the usurper. Note that this is *not* a change of
   behavior, Mastodon already behaves like that, although delivery might be
   unreliable, and the usurper would not have known the former user's
   followers.

* Rename ResubscribeWorker to RefollowWorker

* Process followers in batches
2017-09-12 23:10:40 +02:00

209 lines
5.8 KiB
Ruby

# frozen_string_literal: true
class ResolveRemoteAccountService < BaseService
include OStatus2::MagicKey
include JsonLdHelper
DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0'
# Find or create a local account for a remote user.
# When creating, look up the user's webfinger and fetch all
# important information from their feed
# @param [String] uri User URI in the form of username@domain
# @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)
@account = Account.find_remote(@username, @domain)
return @account unless webfinger_update_due?
Rails.logger.debug "Looking up webfinger for #{uri}"
@webfinger = Goldfinger.finger("acct:#{uri}")
confirmed_username, confirmed_domain = @webfinger.subject.gsub(/\Aacct:/, '').split('@')
if confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero?
@username = confirmed_username
@domain = confirmed_domain
elsif redirected.nil?
return call("#{confirmed_username}@#{confirmed_domain}", update_profile, true)
else
Rails.logger.debug 'Requested and returned acct URIs do not match'
return
end
return if links_missing?
return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
RedisLock.acquire(lock_options) do |lock|
if lock.acquired?
@account = Account.find_remote(@username, @domain)
if activitypub_ready?
handle_activitypub
else
handle_ostatus
end
end
end
@account
rescue Goldfinger::Error => e
Rails.logger.debug "Webfinger query for #{uri} unsuccessful: #{e}"
nil
end
private
def links_missing?
!(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?)
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?
old_public_key = @account.public_key
update_account
update_account_profile if update_profile?
RefollowWorker.perform_async(@account.id) if old_public_key != @account.public_key
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}"
@account = Account.new(username: @username, domain: @domain)
@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 = :ostatus
@account.remote_url = atom_url
@account.salmon_url = salmon_url
@account.url = url
@account.public_key = public_key
@account.uri = canonical_uri
@account.hub_url = hub_url
@account.save!
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
def atom_url
@atom_url ||= @webfinger.link('http://schemas.google.com/g/2010#updates-from').href
end
def salmon_url
@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
def public_key
@public_key ||= magic_key_to_pem(@webfinger.link('magic-public-key').href)
end
def canonical_uri
return @canonical_uri if defined?(@canonical_uri)
author_uri = atom.at_xpath('/xmlns:feed/xmlns:author/xmlns:uri')
if author_uri.nil?
owner = atom.at_xpath('/xmlns:feed').at_xpath('./dfrn:owner', dfrn: DFRN_NS)
author_uri = owner.at_xpath('./xmlns:uri') unless owner.nil?
end
@canonical_uri = author_uri.nil? ? nil : author_uri.content
end
def hub_url
return @hub_url if defined?(@hub_url)
hubs = atom.xpath('//xmlns:link[@rel="hub"]')
@hub_url = hubs.empty? || hubs.first['href'].nil? ? nil : hubs.first['href']
end
def atom_body
return @atom_body if defined?(@atom_body)
response = Request.new(:get, atom_url).perform
raise Mastodon::UnexpectedResponseError, response unless response.code == 200
@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)
end
def update_account_profile
RemoteProfileUpdateWorker.perform_async(@account.id, atom_body.force_encoding('UTF-8'), false)
end
def lock_options
{ redis: Redis.current, key: "resolve:#{@username}@#{@domain}" }
end
end