Mastodonを読む
はじめに  †
前回はフォローについて見てきました。Mastodonの場合、別インスタンスにいるユーザをフォローすることも可能です(リモートフォロー)。前回はローカルのユーザをフォローする場合について主に見たので、今回はリモートフォローについて確認し、インスタンス間でどのようなデータのやり取りがされるのかについて見ていきましょう。
リモートフォローのやり方  †
別インスタンスにいるユーザをどう見つけるかですが、今回は以下のようにユーザがいるインスタンスのアカウントページ(/@ユーザ名)を直接表示して「リモートフォロー」ボタンをクリックするという想定でコードを見ていきます。

「リモートフォロー」ボタンをクリックすると、以下のように「/users/ユーザ名/remote_follow」にジャンプします。ここで、自分の情報を入力し、「フォローする」をクリックするとリモートフォローが行われます。

app/controllers/remote_follow_controller.rb  †
routes.rbを確認すると以下のようになっています。
|   1
  2
  3
  4
 | -
|
|
|
 |   resources :accounts, path: 'users', only: [:show], param: :username do
 
    get :remote_follow,  to: 'remote_follow#new'
    post :remote_follow, to: 'remote_follow#create'
 | 
remote_followはRemoteFollowControllerで処理されるようです。なんでわざわざ分けてるんだろう。AccountsController内に全部書くとごちゃごちゃするからですかね。
newは置いといてcreateメソッド。
|   1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 | -
|
|
-
|
|
|
-
|
|
!
|
|
|
|
|
|
!
|
|
|
!
 |   def create
    @remote_follow = RemoteFollow.new(resource_params)
 
    if @remote_follow.valid?
      resource          = Goldfinger.finger("acct:#{@remote_follow.acct}")
      redirect_url_link = resource&.link('http://ostatus.org/schema/1.0/subscribe')
 
      if redirect_url_link.nil? || redirect_url_link.template.nil?
        @remote_follow.errors.add(:acct, I18n.t('remote_follow.missing_resource'))
        render(:new) && return
      end
 
      session[:remote_follow] = @remote_follow.acct
 
      redirect_to Addressable::Template.new(redirect_url_link.template).expand(uri: @account.to_webfinger_s).to_s
    else
      render :new
    end
  rescue Goldfinger::Error
    @remote_follow.errors.add(:acct, I18n.t('remote_follow.missing_resource'))
    render :new
  end
 | 
「&.」という書き方はRuby2.3からできるようになったようで、変数(この場合resource)がnilを指していたらnilを返す、そうじゃなかったらメソッドを呼んでみるという動作をするようです。
Goldfingerは前回も確認したようにWebFingerを実装するgemで、「/.well-known/webfinger」にアクセスしてユーザ情報の取得を行います。というわけで、「/.well-known/webfinger?resource=junjis0203@mastodon.dev」にアクセスしてみると以下のようなJSONが返されます。
|   1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 | {
  "subject":"acct:junjis0203@mastodon.dev",
  "aliases":[
    "http://mastodon.dev/@junjis0203",
    "http://mastodon.dev/users/junjis0203"
  ],
  "links":[
    {"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"http://mastodon.dev/@junjis0203"},
    {"rel":"http://schemas.google.com/g/2010#updates-from","type":"application/atom+xml","href":"http://mastodon.dev/users/junjis0203.atom"},
    {"rel":"self","type":"application/activity+json","href":"http://mastodon.dev/@junjis0203"},
    {"rel":"salmon","href":"http://mastodon.dev/api/salmon/1"},
    {"rel":"magic-public-key","href":"data:application/magic-public-key,略"},
    {"rel":"http://ostatus.org/schema/1.0/subscribe","template":"http://mastodon.dev/authorize_follow?acct={uri}"}
  ]
}
 | 
createメソッドでは「http://ostatus.org/schema/1.0/subscribe」のリンクを取得しているので、「http://mastodon.dev/authorize_follow?acct={uri}」へのアクセスが行われると推測できます。またその際、{uri}の部分はフォロー対象を示すURI(今回の場合、「acct:admin@mastodon.dev」)に置き換えてリダイレクトが行われます。
開発環境のままだとわかりにくいので、自分がmstdn.jp(インスタンスA)にアカウントを持っていて、pawoo.net(インスタンスB)にいるユーザをフォローする場合を考えましょう。その場合、pawoo.netのサーバ(インスタンスB)からmstdn.jpのサーバ(インスタンスA)に「https//mstdn.jp/authorize_follow?acct=acct:hogehoge@pawoo.net」とリダイレクトが行われます。
app/models/remote_follow.rb  †
RemoteFollowは一見、ActiveRecordを継承したクラスに見えますが、実はそうではありません。
|   1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 | -
|
|
|
|
|
|
-
|
!
!
 | class RemoteFollow
  include ActiveModel::Validations
 
  attr_accessor :acct
 
  validates :acct, presence: true
 
  def initialize(attrs = {})
    @acct = attrs[:acct].strip unless attrs[:acct].nil?
  end
end
 | 
ActiveModel::Validationsをincludeすればvalid?とかerrorsが使えるようになるんですね。DBに保存する必要はないけどActiveRecordっぽく使いたいという場合の書き方として参考になります。
app/controllers/authorize_follows_controller.rb  †
ここから、処理が行われるインスタンスが変わります。具体的には、remote_followで入力したアカウントのインスタンス(インスタンスA)にリダイレクトが行われることになります。
まずはshowメソッド。いろいろやっています。
|   1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 | -
|
!
 
-
-
|
|
|
!
!
 
-
|
!
 
-
|
!
 
-
|
!
 
-
|
!
 
-
|
!
 |   def show
    @account = located_account || render(:error)
  end
 
  def located_account
    if acct_param_is_url?
      account_from_remote_fetch
    else
      account_from_remote_follow
    end
  end
 
  def acct_param_is_url?
    parsed_uri.path && %w[http https].include?(parsed_uri.scheme)
  end
 
  def parsed_uri
    Addressable::URI.parse(acct_without_prefix).normalize
  end
 
  def acct_without_prefix
    acct_params.gsub(/\Aacct:/, '')
  end
 
  def acct_params
    params.fetch(:acct, '')
  end
 
  def account_from_remote_follow
    FollowRemoteAccountService.new.call(acct_without_prefix)
  end
 | 
acctパラメータで渡されているのは「acct:admin@mastodon.dev」みたいな形式なので、今回はFollowRemoteAccountServiceが実行されます。
app/services/follow_remote_account_service.rb  †
FollowRemoteAccountServiceは前回も出てきました。前回は概要だけ説明しましたが、今回はちゃんと見ていきましょう。
|   1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 | -
|
|
|
|
-
|
|
|
|
|
|
 |   def call(uri, redirected = nil)
    username, domain = uri.split('@')
 
    return Account.find_local(username) if TagManager.instance.local_domain?(domain)
 
    account = Account.find_remote(username, domain)
    return account unless account_needs_webfinger_update?(account)
 | 
まずここまで。uriで渡されたのはリモートフォローしようとしているアカウントなのでlocalはありません。インスタンス内にいる他のアカウントがすでにフォローしている場合はfind_remoteでオブジェクトが返されますがそこで終わると面白くないのでnilが返されたとしましょう。
|   1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 |  
 
 
 
 
 
-
!
 
-
|
|
!
 
 
 
 
-
|
|
|
|
|
|
|
|
|
!
 |     Rails.logger.debug "Looking up webfinger for #{uri}"
 
    data = Goldfinger.finger("acct:#{uri}")
 
    raise Goldfinger::Error, 'Missing resource links' if data.link('http://schemas.google.com/g/2010#updates-from').nil? || data.link('salmon').nil? || data.link('http://webfinger.net/rel/profile-page').nil? || data.link('magic-public-key').nil?
 
    confirmed_username, confirmed_domain = data.subject.gsub(/\Aacct:/, '').split('@')
 
    unless confirmed_username.casecmp(username).zero? && confirmed_domain.casecmp(domain).zero?
      return call("#{confirmed_username}@#{confirmed_domain}", true) if redirected.nil?
      raise Goldfinger::Error, 'Requested and returned acct URI do not match'
    end
 
    return Account.find_local(confirmed_username) if TagManager.instance.local_domain?(confirmed_domain)
 
    confirmed_account = Account.find_remote(confirmed_username, confirmed_domain)
    if confirmed_account.nil?
      Rails.logger.debug "Creating new remote account for #{uri}"
 
      domain_block = DomainBlock.find_by(domain: domain)
      account = Account.new(username: confirmed_username, domain: confirmed_domain)
      account.suspended   = true if domain_block && domain_block.suspend?
      account.silenced    = true if domain_block && domain_block.silence?
      account.private_key = nil
    else
      account = confirmed_account
    end
 | 
WebFingerでフォロー対象のアカウントの情報を取得しています。今回は、「フォロー動作をするアカウントがいるインスタンスから、フォロー対象のアカウントがいるインスタンス」にWebFingerが行われます。先ほどのと並べて整理しましょう。
- 1回目のWebFinger
- リモートフォロー対象がいるインスタンスBから、入力されたアカウントがいるインスタンスAにWebFinger(リダイレクト先を得るため)
- 2回目のWebFinger
- 入力されたアカウントがいるインスタンスAから、リモートフォロー対象がいるインスタンスBにWebFinger(Accountを作成するため)
と相互にWebFingerが使用されています。で、取得した情報を使ってAccountを作成すると。
|   1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 |  
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 |     account.last_webfingered_at = Time.now.utc
 
    account.remote_url  = data.link('http://schemas.google.com/g/2010#updates-from').href
    account.salmon_url  = data.link('salmon').href
    account.url         = data.link('http://webfinger.net/rel/profile-page').href
    account.public_key  = magic_key_to_pem(data.link('magic-public-key').href)
 
    body, xml = get_feed(account.remote_url)
    hubs      = get_hubs(xml)
 
    account.uri     = get_account_uri(xml)
    account.hub_url = hubs.first.attribute('href').value
 
    account.save!
    get_profile(body, account)
 
    account
  end
 | 
残り。各種URLが設定されています。
createメソッド  †
さて、というわけでフォロー対象のインスタンスから情報を取得しAccountを作成、showのビュー表示が行われます。
そして、フォローボタンが押されると今度は実際のフォロー処理です。
|   1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 | -
|
|
-
|
|
|
!
|
|
!
 
 
 
-
|
!
 |   def create
    @account = follow_attempt.try(:target_account)
 
    if @account.nil?
      render :error
    else
      redirect_to web_url("accounts/#{@account.id}")
    end
  rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
    render :error
  end
 
  private
 
  def follow_attempt
    FollowService.new.call(current_account, acct_without_prefix)
  end
 | 
FollowServiceは前回も出てきました。
|   1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 | -
|
|
-
|
|
-
|
|
|
|
|
-
|
|
|
!
!
 | class FollowService < BaseService
  include StreamEntryRenderer
 
  def call(source_account, uri)
    target_account = FollowRemoteAccountService.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)
 
    if target_account.locked?
      request_follow(source_account, target_account)
    else
      direct_follow(source_account, target_account)
    end
  end
 | 
さらにdirect_followメソッド。
|   1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 | -
|
|
-
|
|
|
|
|
!
|
|
|
|
!
 |   def direct_follow(source_account, target_account)
    follow = source_account.follow!(target_account)
 
    if target_account.local?
      NotifyService.new.call(target_account, follow)
    else
      Pubsubhubbub::SubscribeWorker.perform_async(target_account.id) unless target_account.subscribed?
      NotificationWorker.perform_async(build_follow_xml(follow), source_account.id, target_account.id)
      AfterRemoteFollowWorker.perform_async(follow.id)
    end
 
    MergeWorker.perform_async(target_account.id, source_account.id)
 
    follow
  end
 | 
前回はさらっと流しましたが今回はNotificationWorkerの先を追いかけます。
なお、build_follow_xmlメソッドでは以下のようなXMLができるようです。
|   1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 | <?xml version="1.0"?>
<entry xmlns="http://www.w3.org/2005/Atom" 
  xmlns:thr="http://purl.org/syndication/thread/1.0" 
  xmlns:activity="http://activitystrea.ms/spec/1.0/" 
  xmlns:poco="http://portablecontacts.net/spec/1.0" 
  xmlns:media="http://purl.org/syndication/atommedia" 
  xmlns:ostatus="http://ostatus.org/schema/1.0" 
  xmlns:mastodon="http://mastodon.social/schema/1.0">
  <id>tag:localhost:3000,2017-05-17:objectId=3:objectType=Follow</id>
  <title>admin started following junjis0203</title>
  <content type="html">admin started following junjis0203</content>
  <author>
    <id>http://localhost:3000/users/admin</id>
    <activity:object-type>http://activitystrea.ms/schema/1.0/person</activity:object-type>
    <uri>http://localhost:3000/users/admin</uri>
    <name>admin</name>
    <email>admin@localhost:3000</email>
    <link rel="alternate" type="text/html" href="http://localhost:3000/@admin"/>
    <link rel="avatar" type="" media:width="120" media:height="120" href="http://localhost:3000/avatars/original/missing.png"/>
    <link rel="header" type="" media:width="700" media:height="335" href="http://localhost:3000/headers/original/missing.png"/>
    <poco:preferredUsername>admin</poco:preferredUsername>
    <mastodon:scope>public</mastodon:scope>
  </author>
  <activity:object-type>http://activitystrea.ms/schema/1.0/activity</activity:object-type>
  <activity:verb>http://activitystrea.ms/schema/1.0/follow</activity:verb>
  <activity:object>
    <id>http://localhost:3000/users/junjis0203</id>
    <activity:object-type>http://activitystrea.ms/schema/1.0/person</activity:object-type>
    <uri>http://localhost:3000/users/junjis0203</uri>
    <name>junjis0203</name>
    <email>junjis0203@localhost:3000</email>
    <link rel="alternate" type="text/html" href="http://localhost:3000/@junjis0203"/>
    <link rel="avatar" type="" media:width="120" media:height="120" href="http://localhost:3000/avatars/original/missing.png"/>
    <link rel="header" type="" media:width="700" media:height="335" href="http://localhost:3000/headers/original/missing.png"/>
    <poco:preferredUsername>junjis0203</poco:preferredUsername>
    <mastodon:scope>public</mastodon:scope>
  </activity:object>
</entry>
 | 
上記のXMLは画面でadmin→junjis0203のフォローを行った後、rails consoleで以下のようにコードを叩き出力しました。
a = Account.find_local('admin')
f = a.active_relationships.first
puts AtomSerializer.render(AtomSerializer.new.follow_salmon(f))
app/works/notification_worker.rb  †
NotificationWorker。
|   1
  2
  3
  4
  5
  6
  7
  8
  9
 | -
|
|
|
|
-
|
!
!
 | class NotificationWorker
  include Sidekiq::Worker
 
  sidekiq_options queue: 'push', retry: 5
 
  def perform(xml, source_account_id, target_account_id)
    SendInteractionService.new.call(xml, Account.find(source_account_id), Account.find(target_account_id))
  end
end
 | 
SendInteractionService呼んでるだけです。
|   1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 | -
-
|
|
|
-
|
|
!
|
|
|
-
|
!
!
 | class SendInteractionService < BaseService
  def call(xml, source_account, target_account)
    envelope = salmon.pack(xml, source_account.keypair)
    salmon.post(target_account.salmon_url, envelope)
  end
 
  private
 
  def salmon
    @salmon ||= OStatus2::Salmon.new
  end
end
 | 
こちらもSalmonにポストしてるだけ。ですが、ここでまたインスタンスの交代が起こります。今度は、フォローされる側(インスタンスB)の「/api/salmon/:id」が呼び出されることになります。
app/controllers/api/salmon_controller.rb  †
というわけで「/api/salmon/:id」の処理。updateメソッドが呼び出されます。
|   1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 | -
|
|
|
-
|
|
-
|
|
|
|
!
!
 | class Api::SalmonController < ApiController
  before_action :set_account
  respond_to :txt
 
  def update
    payload = request.body.read
 
    if !payload.nil? && verify?(payload)
      SalmonWorker.perform_async(@account.id, payload.force_encoding('UTF-8'))
      head 201
    else
      head 202
    end
  end
 | 
SalmonWorker。ProcessInteractionService呼んでるだけです。
|   1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 | -
|
|
|
|
-
|
|
|
!
!
 | class SalmonWorker
  include Sidekiq::Worker
 
  sidekiq_options backtrace: true
 
  def perform(account_id, body)
    ProcessInteractionService.new.call(body, Account.find(account_id))
  rescue Nokogiri::XML::XPath::SyntaxError, ActiveRecord::RecordNotFound
    true
  end
end
 | 
ProcessInteractionService。
|   1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 | -
|
|
-
|
|
-
|
|
|
|
|
|
|
|
|
-
|
|
|
|
|
 | class ProcessInteractionService < BaseService
  include AuthorExtractor
 
  def call(envelope, target_account)
    body = salmon.unpack(envelope)
 
    xml = Nokogiri::XML(body)
    xml.encoding = 'utf-8'
 
    account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: TagManager::XMLNS))
 
    return if account.nil? || account.suspended?
 
    if salmon.verify(envelope, account.keypair)
      RemoteProfileUpdateWorker.perform_async(account.id, body.force_encoding('UTF-8'), true)
 
      case verb(xml)
      when :follow
        follow!(account, target_account) unless target_account.locked? || target_account.blocking?(account)
 | 
author_from_xmlはAuthorExtractor(app/services/concerns/author_extractor.rbに書かれています)で定義されているメソッドで、Atomから「ユーザ名@インスタンスドメイン名」を取得、FollowRemoteAccountServiceを使って「フォロー動作を行うアカウント(インスタンスAにいるアカウント)」をインスタンスBに作成しています。
verbメソッドで<activity:verb>に書かれている動作を取得、それに応じて処理の分岐が行われます。follow!では「インスタンスB上に作られた」「インスタンスAにいるアカウント」が「インスタンスBのアカウント」をフォローするという処理を行います。
つまり、インスタンスA(リモートフォローした人がいるインスタンス)、インスタンスB(リモートフォローされた人がいるインスタンス)双方でフォロー情報の同期が行われる、ということになります。
おわりに  †
今回はリモートフォローについて見てきました。リモートフォローでは、
- リモートフォロー対象がいるインスタンス(インスタンスB)での処理
- 対象をフォローするアカウントがいるインスタンス(インスタンスA)での処理
- さらに情報同期のためにインスタンスAからインスタンスBに情報が送られ処理
とインスタンスをまたいで情報のやり取りが行われていました。処理が行われるインスタンスが行ったり来たりするのでややこしいですが、このあたりがMastodon(というかGNU Social?)の長所であるインスタンス連合の話になるのでしっかり理解する必要があります。