[[Mastodonを読む]] #contents *はじめに [#x21a0437] 前回はフォローについて見てきました。Mastodonの場合、別インスタンスにいるユーザをフォローすることも可能です(リモートフォロー)。前回はローカルのユーザをフォローする場合について主に見たので、今回はリモートフォローについて確認し、インスタンス間でどのようなデータのやり取りがされるのかについて見ていきましょう。 *リモートフォローのやり方 [#pd234e56] 別インスタンスにいるユーザをどう見つけるかですが、今回は以下のようにユーザがいるインスタンスのアカウントページ(/@ユーザ名)を直接表示して「リモートフォロー」ボタンをクリックするという想定でコードを見ていきます。 &ref(account-page.jpg); 「リモートフォロー」ボタンをクリックすると、以下のように「/users/ユーザ名/remote_follow」にジャンプします。ここで、自分の情報を入力し、「フォローする」をクリックするとリモートフォローが行われます。 &ref(remote-follow.jpg); *app/controllers/remote_follow_controller.rb [#jf8ca318] routes.rbを確認すると以下のようになっています。 #code(Ruby){{ 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メソッド。 #code(Ruby){{ 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が返されます。 #code{{ { "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 [#b71ac29f] RemoteFollowは一見、ActiveRecordを継承したクラスに見えますが、実はそうではありません。 #code(Ruby){{ 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 [#z1c19362] ここから、処理が行われるインスタンスが変わります。具体的には、remote_followで入力したアカウントのインスタンス(インスタンスA)にリダイレクトが行われることになります。 まずはshowメソッド。いろいろやっています。 #code(Ruby){{ 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 [#e41416bb] FollowRemoteAccountServiceは前回も出てきました。前回は概要だけ説明しましたが、今回はちゃんと見ていきましょう。 #code(Ruby){{ # 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, 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が返されたとしましょう。 #code(Ruby){{ 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? 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? # Disallow account hijacking 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を作成すると。 #code(Ruby){{ 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メソッド [#e1f7309b] さて、というわけでフォロー対象のインスタンスから情報を取得しAccountを作成、showのビュー表示が行われます。 そして、フォローボタンが押されると今度は実際のフォロー処理です。 #code(Ruby){{ 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は前回も出てきました。 #code(Ruby){{ class FollowService < BaseService include StreamEntryRenderer # 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 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メソッド。 #code(Ruby){{ 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ができるようです。 #code{{ <?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 [#gff24b21] NotificationWorker。 #code(Ruby){{ 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呼んでるだけです。 #code(Ruby){{ class SendInteractionService < BaseService # Send an Atom representation of an interaction to a remote Salmon endpoint # @param [String] Entry XML # @param [Account] source_account # @param [Account] target_account 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 [#uee7afca] というわけで「/api/salmon/:id」の処理。updateメソッドが呼び出されます。 #code(Ruby){{ 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呼んでるだけです。 #code(Ruby){{ 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。 #code(Ruby){{ class ProcessInteractionService < BaseService include AuthorExtractor # Record locally the remote interaction with our user # @param [String] envelope Salmon envelope # @param [Account] target_account Account the Salmon was addressed to 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(リモートフォローされた人がいるインスタンス)双方でフォロー情報の同期が行われる、ということになります。 *おわりに [#e97721a1] 今回はリモートフォローについて見てきました。リモートフォローでは、 +リモートフォロー対象がいるインスタンス(インスタンスB)での処理 +対象をフォローするアカウントがいるインスタンス(インスタンスA)での処理 +さらに情報同期のためにインスタンスAからインスタンスBに情報が送られ処理 とインスタンスをまたいで情報のやり取りが行われていました。処理が行われるインスタンスが行ったり来たりするのでややこしいですが、このあたりがMastodon(というかGNU Social?)の長所であるインスタンス連合の話になるのでしっかり理解する必要があります。