Open13

Mastodonサーバがリモートからポストを受け取ったらやること (書きかけ)

ピン留めされたアイテム
zundazunda

ActivityPubでフォロワーの多いアカウントがポストをブーストした時にポスト元のサーバにどのようなリクエストが送られるのか議論している時に、僕はポストを受け取ったサーバがポストを作成したアカウントのrel="me"リンクの検証をしないと思い込んでいました。実際には、ポストを受け取ったサーバがポストを作成したアカウントのプロファイルを表示するので、rel="me"リンクの検証が必要そうです。この他、プレビューカードを作成するために元のポストの内容を取得する必要もありそうです。コードを読んで、ブーストされたポストを受け取ったMastodonサーバがどのような処理をおこなうのか想像してみます。参照する対象のコードは4.2.0ごろのものです。

参照対象のコードの著作権表示とライセンス

Copyright (C) 2016-2023 Eugen Rochko & other Mastodon contributors (see AUTHORS.md)

This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License along with this program. If not, see https://www.gnu.org/licenses/.

zundazunda

ポストの到着

手元のMastodonサーバへのポストは、/inboxへのPOSTリクエストとして届くようです。Railsのログにはcontroller=ActivityPub::InboxesController action=createと一緒にkeyが記録されていて、202を返却するようです。

app/controllers/activitypub/inboxes_controller.rb
  def create
    upgrade_account
    process_collection_synchronization
    process_payload
    head 202
  end
zundazunda

ActivityPub::InboxesController#upgrade_account

OStatusからの移行パスかな?

app/controllers/activitypub/inboxes_controller.rb
  def upgrade_account
    if signed_request_account&.ostatus?
      signed_request_account.update(last_webfingered_at: nil)
      ResolveAccountWorker.perform_async(signed_request_account.acct)
    end

    DeliveryFailureTracker.reset!(signed_request_actor.inbox_url)
  end

DeliveryFailureTracker.reset!はローカルで保持してる情報の更新しかしないようだ。

app/lib/delivery_failure_tracker.rb
class DeliveryFailureTracker
  :
  def track_success!
    redis.del(exhausted_deliveries_key)
    UnavailableDomain.find_by(domain: @host)&.destroy
  end
  :
  alias reset! track_success!
  :
zundazunda

ActivityPub::InboxesController#process_collection_synchronization

Collection-Synchronizationリクエストヘッダが来ていた場合の処理。https://socialhub.activitypub.rocks/t/fep-8fcf-followers-collection-synchronization-across-servers/1172 かな?

app/controllers/activitypub/inboxes_controller.rb
  def process_collection_synchronization
    raw_params = request.headers['Collection-Synchronization']
    return if raw_params.blank? || ENV['DISABLE_FOLLOWERS_SYNCHRONIZATION'] == 'true' || signed_request_account.nil?

    # Re-using the syntax for signature parameters
    tree   = SignatureParamsParser.new.parse(raw_params)
    params = SignatureParamsTransformer.new.apply(tree)

    ActivityPub::PrepareFollowersSynchronizationService.new.call(signed_request_account, params)
  rescue Parslet::ParseFailed
    Rails.logger.warn 'Error parsing Collection-Synchronization header'
  end
zundazunda

ActivityPub::InboxesController#process_payload

ここを追っていかないといけなさそう。

signed_request_actorが呼ばれるのはperform_asyncよりperformが実行された時ではなく、process_payloadが実行された時だろう。このメソッドで例外が起きた場合にはレスポンスコードが202ではない適切なものになるのかもしれない。

app/controllers/activitypub/inboxes_controller.rb
  def process_payload
    ActivityPub::ProcessingWorker.perform_async(signed_request_actor.id, body, @account&.id, signed_request_actor.class.name)
  end
app/workers/activitypub/processing_worker.rb
class ActivityPub::ProcessingWorker
  include Sidekiq::Worker

  sidekiq_options queue: 'ingress', backtrace: true, retry: 8

  def perform(actor_id, body, delivered_to_account_id = nil, actor_type = 'Account')
    case actor_type
    when 'Account'
      actor = Account.find_by(id: actor_id)
    end

    return if actor.nil?

    ActivityPub::ProcessCollectionService.new.call(body, actor, override_timestamps: true, delivered_to_account_id: delivered_to_account_id, delivery: true)
  rescue ActiveRecord::RecordInvalid => e
    Rails.logger.debug { "Error processing incoming ActivityPub object: #{e}" }
  end
end
zundazunda

ActivityPub::ProcessCollectionService.new.call

app/services/activitypub/process_collection_service.rb
# frozen_string_literal: true

class ActivityPub::ProcessCollectionService < BaseService
  include JsonLdHelper

  def call(body, actor, **options)
    @account = actor
    @json    = original_json = Oj.load(body, mode: :strict)
    @options = options

    return unless @json.is_a?(Hash)

    begin
      @json = compact(@json) if @json['signature'].is_a?(Hash)
    rescue JSON::LD::JsonLdError => e
      Rails.logger.debug { "Error when compacting JSON-LD document for #{value_or_id(@json['actor'])}: #{e.message}" }
      @json = original_json.without('signature')
    end

    return if !supported_context? || (different_actor? && verify_account!.nil?) || suspended_actor? || @account.local?
    return unless @account.is_a?(Account)

    if @json['signature'].present?
      # We have verified the signature, but in the compaction step above, might
      # have introduced incompatibilities with other servers that do not
      # normalize the JSON-LD documents (for instance, previous Mastodon
      # versions), so skip redistribution if we can't get a safe document.
      patch_for_forwarding!(original_json, @json)
      @json.delete('signature') unless safe_for_forwarding?(original_json, @json)
    end

    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
  end

  def suspended_actor?
    @account.suspended? && !activity_allowed_while_suspended?
  end

  def activity_allowed_while_suspended?
    %w(Delete Reject Undo Update).include?(@json['type'])
  end

  def process_items(items)
    items.reverse_each.filter_map { |item| process_item(item) }
  end

  def supported_context?
    super(@json)
  end

  def process_item(item)
    activity = ActivityPub::Activity.factory(item, @account, **@options)
    activity&.perform
  end

  def verify_account!
    @options[:relayed_through_actor] = @account
    @account = ActivityPub::LinkedDataSignature.new(@json).verify_actor!
    @account = nil unless @account.is_a?(Account)
    @account
  rescue JSON::LD::JsonLdError, RDF::WriterError => e
    Rails.logger.debug { "Could not verify LD-Signature for #{value_or_id(@json['actor'])}: #{e.message}" }
    nil
  end
end
app/helpers/jsonld_helper.rb
# frozen_string_literal: true

module JsonLdHelper
  include ContextHelper

  def equals_or_includes?(haystack, needle)
    haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle
  end

  def equals_or_includes_any?(haystack, needles)
    needles.any? { |needle| equals_or_includes?(haystack, needle) }
  end

  def first_of_value(value)
    value.is_a?(Array) ? value.first : value
  end

  def uri_from_bearcap(str)
    if str&.start_with?('bear:')
      Addressable::URI.parse(str).query_values['u']
    else
      str
    end
  end

  # The url attribute can be a string, an array of strings, or an array of objects.
  # The objects could include a mimeType. Not-included mimeType means it's text/html.
  def url_to_href(value, preferred_type = nil)
    single_value = if value.is_a?(Array) && !value.first.is_a?(String)
                     value.find { |link| preferred_type.nil? || ((link['mimeType'].presence || 'text/html') == preferred_type) }
                   elsif value.is_a?(Array)
                     value.first
                   else
                     value
                   end

    if single_value.nil? || single_value.is_a?(String)
      single_value
    else
      single_value['href']
    end
  end

  def as_array(value)
    if value.nil?
      []
    elsif value.is_a?(Array)
      value
    else
      [value]
    end
  end

  def value_or_id(value)
    value.is_a?(String) || value.nil? ? value : value['id']
  end

  def supported_context?(json)
    !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT)
  end

  def unsupported_uri_scheme?(uri)
    uri.nil? || !uri.start_with?('http://', 'https://')
  end

  def non_matching_uri_hosts?(base_url, comparison_url)
    return true if unsupported_uri_scheme?(comparison_url)

    needle = Addressable::URI.parse(comparison_url).host
    haystack = Addressable::URI.parse(base_url).host

    !haystack.casecmp(needle).zero?
  end

  def canonicalize(json)
    graph = RDF::Graph.new << JSON::LD::API.toRdf(json, documentLoader: method(:load_jsonld_context))
    graph.dump(:normalize)
  end

  def compact(json)
    compacted = JSON::LD::API.compact(json.without('signature'), full_context, documentLoader: method(:load_jsonld_context))
    compacted['signature'] = json['signature']
    compacted
  end

  # Patches a JSON-LD document to avoid compatibility issues on redistribution
  #
  # Since compacting a JSON-LD document against Mastodon's built-in vocabulary
  # means other extension namespaces will be expanded, malformed JSON-LD
  # attributes lost, and some values “unexpectedly” compacted this method
  # patches the following likely sources of incompatibility:
  # - 'https://www.w3.org/ns/activitystreams#Public' being compacted to
  #   'as:Public' (for instance, pre-3.4.0 Mastodon does not understand
  #   'as:Public')
  # - single-item arrays being compacted to the item itself (`[foo]` being
  #   compacted to `foo`)
  #
  # It is not always possible for `patch_for_forwarding!` to produce a document
  # deemed safe for forwarding. Use `safe_for_forwarding?` to check the status
  # of the output document.
  #
  # @param original [Hash] The original JSON-LD document used as reference
  # @param compacted [Hash] The compacted JSON-LD document to be patched
  # @return [void]
  def patch_for_forwarding!(original, compacted)
    original.without('@context', 'signature').each do |key, value|
      next if value.nil? || !compacted.key?(key)

      compacted_value = compacted[key]
      if value.is_a?(Hash) && compacted_value.is_a?(Hash)
        patch_for_forwarding!(value, compacted_value)
      elsif value.is_a?(Array)
        compacted_value = [compacted_value] unless compacted_value.is_a?(Array)
        return if value.size != compacted_value.size

        compacted[key] = value.zip(compacted_value).map do |v, vc|
          if v.is_a?(Hash) && vc.is_a?(Hash)
            patch_for_forwarding!(v, vc)
            vc
          elsif v == 'https://www.w3.org/ns/activitystreams#Public' && vc == 'as:Public'
            v
          else
            vc
          end
        end
      elsif value == 'https://www.w3.org/ns/activitystreams#Public' && compacted_value == 'as:Public'
        compacted[key] = value
      end
    end
  end

  # Tests whether a JSON-LD compaction is deemed safe for redistribution,
  # that is, if it doesn't change its meaning to consumers that do not actually
  # handle JSON-LD, but rely on values being serialized in a certain way.
  #
  # See `patch_for_forwarding!` for details.
  #
  # @param original [Hash] The original JSON-LD document used as reference
  # @param compacted [Hash] The compacted JSON-LD document to be patched
  # @return [Boolean] Whether the patched document is deemed safe
  def safe_for_forwarding?(original, compacted)
    original.without('@context', 'signature').all? do |key, value|
      compacted_value = compacted[key]
      return false unless value.class == compacted_value.class

      if value.is_a?(Hash)
        safe_for_forwarding?(value, compacted_value)
      elsif value.is_a?(Array)
        value.zip(compacted_value).all? do |v, vc|
          v.is_a?(Hash) ? (vc.is_a?(Hash) && safe_for_forwarding?(v, vc)) : v == vc
        end
      else
        value == compacted_value
      end
    end
  end

  def fetch_resource(uri, id, on_behalf_of = nil)
    unless id
      json = fetch_resource_without_id_validation(uri, on_behalf_of)

      return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id'])

      uri = json['id']
    end

    json = fetch_resource_without_id_validation(uri, on_behalf_of)
    json.present? && json['id'] == uri ? json : nil
  end

  def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false)
    on_behalf_of ||= Account.representative

    build_request(uri, on_behalf_of).perform do |response|
      raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error

      body_to_json(response.body_with_limit) if response.code == 200
    end
  end

  def body_to_json(body, compare_id: nil)
    json = body.is_a?(String) ? Oj.load(body, mode: :strict) : body

    return if compare_id.present? && json['id'] != compare_id

    json
  rescue Oj::ParseError
    nil
  end

  def merge_context(context, new_context)
    if context.is_a?(Array)
      context << new_context
    else
      [context, new_context]
    end
  end

  def response_successful?(response)
    (200...300).cover?(response.code)
  end

  def response_error_unsalvageable?(response)
    response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code))
  end

  def build_request(uri, on_behalf_of = nil)
    Request.new(:get, uri).tap do |request|
      request.on_behalf_of(on_behalf_of) if on_behalf_of
      request.add_headers('Accept' => 'application/activity+json, application/ld+json')
    end
  end

  def load_jsonld_context(url, _options = {}, &block)
    json = Rails.cache.fetch("jsonld:context:#{url}", expires_in: 30.days, raw: true) do
      request = Request.new(:get, url)
      request.add_headers('Accept' => 'application/ld+json')
      request.perform do |res|
        raise JSON::LD::JsonLdError::LoadingDocumentFailed unless res.code == 200 && res.mime_type == 'application/ld+json'

        res.body_with_limit
      end
    end

    doc = JSON::LD::API::RemoteDocument.new(json, documentUrl: url)

    block ? yield(doc) : doc
  end
end
zundazunda

signed_request_actor

署名が有効なのは、

  • Signatureリクエストヘッダが存在し、
  • SignatureリクエストヘッダにkeyIdsignatureパラメータが存在し、
  • 署名アルゴリズムがサポートされたもので、かつ、
  • 署名時刻がある程度の範囲内にある場合

で、その場合、署名の強度を確認して、ダイジェストの照合をおこない、actor_from_key_id(signature_params['keyId'])からResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false)を呼ぶようだ。

この段階でローカルに持っている公開鍵で署名の検証をおこない、検証が失敗した場合にはactor_refresh_key!(actor)からActivityPub::FetchRemoteActorService.new.call(actor.uri, only_key: true, suppress_errors: false)を呼び公開鍵を取得しなおしてから再度署名を検証する。

app/controllers/concerns/signature_verification.rb
  def signed_request_actor
    return @signed_request_actor if defined?(@signed_request_actor)

    raise SignatureVerificationError, 'Request not signed' unless signed_request?
    raise SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters?
    raise SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm)
    raise SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?

    verify_signature_strength!
    verify_body_digest!

    actor = actor_from_key_id(signature_params['keyId'])

    raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?

    signature             = Base64.decode64(signature_params['signature'])
    compare_signed_string = build_signed_string

    return actor unless verify_signature(actor, signature, compare_signed_string).nil?

    actor = stoplight_wrap_request { actor_refresh_key!(actor) }

    raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?

    return actor unless verify_signature(actor, signature, compare_signed_string).nil?

    fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature']
  rescue SignatureVerificationError => e
    fail_with! e.message
  rescue HTTP::Error, OpenSSL::SSL::SSLError => e
    fail_with! "Failed to fetch remote data: #{e.message}"
  rescue Mastodon::UnexpectedResponseError
    fail_with! 'Failed to fetch remote data (got unexpected reply from server)'
  rescue Stoplight::Error::RedLight
    fail_with! 'Fetching attempt skipped because of recent connection failure'
  end

  def signed_request?
    request.headers['Signature'].present?
  end

  def missing_required_signature_parameters?
    signature_params['keyId'].blank? || signature_params['signature'].blank?
  end

  def signature_params
    @signature_params ||= begin
      raw_signature = request.headers['Signature']
      tree          = SignatureParamsParser.new.parse(raw_signature)
      SignatureParamsTransformer.new.apply(tree)
    end
  rescue Parslet::ParseFailed
    raise SignatureVerificationError, 'Error parsing signature parameters'
  end

  def actor_from_key_id(key_id)
    domain = key_id.start_with?('acct:') ? key_id.split('@').last : key_id

    if domain_not_allowed?(domain)
      @signature_verification_failure_code = 403
      return
    end

    if key_id.start_with?('acct:')
      stoplight_wrap_request { ResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false) }
    elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
      account   = ActivityPub::TagManager.instance.uri_to_actor(key_id)
      account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false, suppress_errors: false) }
      account
    end
  rescue Mastodon::PrivateNetworkAddressError => e
    raise SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})"
  rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteActorService::Error, ActivityPub::FetchRemoteKeyService::Error, Webfinger::Error => e
    raise SignatureVerificationError, e.message
  end

  def actor_refresh_key!(actor)
    return if actor.local? || !actor.activitypub?
    return actor.refresh! if actor.respond_to?(:refresh!) && actor.possibly_stale?

    ActivityPub::FetchRemoteActorService.new.call(actor.uri, only_key: true, suppress_errors: false)
  rescue Mastodon::PrivateNetworkAddressError => e
    raise SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})"
  rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteActorService::Error, Webfinger::Error => e
    raise SignatureVerificationError, e.message
  end
zundazunda

ResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false)

  • ここで参照されているuriの内容を確認する。acct:zundan@mastodon.zunda.ninjaの形式だろうか。

既知のリモートのアカウントについてはwebfingerの更新時期になっていない限りローカルで知っている内容を返す。取得あるいは更新が必要な場合にはprocess_webfinger!(@uri)をしたのちに、fetch_account!からActivityPub::FetchRemoteAccountService.new.call(actor_url, suppress_errors: @options[:suppress_errors])を呼ぶ。

app/services/resolve_account_service.rb
  # Find or create an account record for a remote user. When creating,
  # look up the user's webfinger and fetch ActivityPub data
  # @param [String, Account] uri URI in the username@domain format or account record
  # @param [Hash] options
  # @option options [Boolean] :redirected Do not follow further Webfinger redirects
  # @option options [Boolean] :skip_webfinger Do not attempt any webfinger query or refreshing account data
  # @option options [Boolean] :skip_cache Get the latest data from origin even if cache is not due to update yet
  # @option options [Boolean] :suppress_errors When failing, return nil instead of raising an error
  # @return [Account]
  def call(uri, options = {})
    return if uri.blank?

    process_options!(uri, options)

    # First of all we want to check if we've got the account
    # record with the URI already, and if so, we can exit early

    return if domain_not_allowed?(@domain)

    @account ||= Account.find_remote(@username, @domain)

    return @account if @account&.local? || @domain.nil? || !webfinger_update_due?

    # At this point we are in need of a Webfinger query, which may
    # yield us a different username/domain through a redirect
    process_webfinger!(@uri)
    @domain = nil if TagManager.instance.local_domain?(@domain)

    # Because the username/domain pair may be different than what
    # we already checked, we need to check if we've already got
    # the record with that URI, again

    return if domain_not_allowed?(@domain)

    @account ||= Account.find_remote(@username, @domain)

    if gone_from_origin? && not_yet_deleted?
      queue_deletion!
      return
    end

    return @account if @account&.local? || gone_from_origin? || !webfinger_update_due?

    # Now it is certain, it is definitely a remote account, and it
    # either needs to be created, or updated from fresh data

    fetch_account!
  rescue Webfinger::Error => e
    Rails.logger.debug { "Webfinger query for #{@uri} failed: #{e}" }
    raise unless @options[:suppress_errors]
  end

  def fetch_account!
    return unless activitypub_ready?

    with_redis_lock("resolve:#{@username}@#{@domain}") do
      @account = ActivityPub::FetchRemoteAccountService.new.call(actor_url, suppress_errors: @options[:suppress_errors])
    end

    @account
  end
zundazunda

ActivityPub::FetchRemoteActorService.new.call(actor.uri, only_key: true, suppress_errors: false)

ざっくり読んだだけだが、Request.new(:get, uri).perfome do |response|して得られたJSONをパーズして返すようだ。

app/services/activitypub/fetch_remote_actor_service.rb
  # Does a WebFinger roundtrip on each call, unless `only_key` is true
  def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
    return if domain_not_allowed?(uri)
    return ActivityPub::TagManager.instance.uri_to_actor(uri) if ActivityPub::TagManager.instance.local_uri?(uri)

    @json = begin
      if prefetched_body.nil?
        fetch_resource(uri, id)
      else
        body_to_json(prefetched_body, compare_id: id ? uri : nil)
      end
    rescue Oj::ParseError
      raise Error, "Error parsing JSON-LD document #{uri}"
    end

    raise Error, "Error fetching actor JSON at #{uri}" if @json.nil?
    raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?
    raise Error, "Unexpected object type for actor #{uri} (expected any of: #{SUPPORTED_TYPES})" unless expected_type?
    raise Error, "Actor #{uri} has moved to #{@json['movedTo']}" if break_on_redirect && @json['movedTo'].present?
    raise Error, "Actor #{uri} has no 'preferredUsername', which is a requirement for Mastodon compatibility" if @json['preferredUsername'].blank?

    @uri      = @json['id']
    @username = @json['preferredUsername']
    @domain   = Addressable::URI.parse(@uri).normalized_host

    check_webfinger! unless only_key

    ActivityPub::ProcessAccountService.new.call(@username, @domain, @json, only_key: only_key, verified_webfinger: !only_key, request_id: request_id)
  rescue Error => e
    Rails.logger.debug { "Fetching actor #{uri} failed: #{e.message}" }
    raise unless suppress_errors
  end
app/helpers/jsonld_helper.rb
  def fetch_resource(uri, id, on_behalf_of = nil)
    unless id
      json = fetch_resource_without_id_validation(uri, on_behalf_of)

      return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id'])

      uri = json['id']
    end

    json = fetch_resource_without_id_validation(uri, on_behalf_of)
    json.present? && json['id'] == uri ? json : nil
  end

  def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false)
    on_behalf_of ||= Account.representative

    build_request(uri, on_behalf_of).perform do |response|
      raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error

      body_to_json(response.body_with_limit) if response.code == 200
    end
  end

  def build_request(uri, on_behalf_of = nil)
    Request.new(:get, uri).tap do |request|
      request.on_behalf_of(on_behalf_of) if on_behalf_of
      request.add_headers('Accept' => 'application/activity+json, application/ld+json')
    end
  end
zundazunda

ActivityPub::FetchRemoteAccountService.new.call(actor_url, suppress_errors: @options[:suppress_errors])

app/services/activitypub/fetch_remote_account_service.rb
class ActivityPub::FetchRemoteAccountService < ActivityPub::FetchRemoteActorService
  # Does a WebFinger roundtrip on each call, unless `only_key` is true
  def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
    actor = super
    return actor if actor.nil? || actor.is_a?(Account)

    Rails.logger.debug { "Fetching account #{uri} failed: Expected Account, got #{actor.class.name}" }
    raise Error, "Expected Account, got #{actor.class.name}" unless suppress_errors
  end
end
zundazunda

process_webfinger!(@uri)

https://#{@domain}/.well-known/webfinger?resource=#{@uri}形式のURLからJSONを取得するようだ。

app/services/resolve_account_service.rb
  def process_webfinger!(uri)
    @webfinger                           = webfinger!("acct:#{uri}")
    confirmed_username, confirmed_domain = split_acct(@webfinger.subject)

    if confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero?
      @username = confirmed_username
      @domain   = confirmed_domain
      return
    end

    # Account doesn't match, so it may have been redirected
    @webfinger         = webfinger!("acct:#{confirmed_username}@#{confirmed_domain}")
    @username, @domain = split_acct(@webfinger.subject)

    raise Webfinger::RedirectError, "Too many webfinger redirects for URI #{uri} (stopped at #{@username}@#{@domain})" unless confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero?
  rescue Webfinger::GoneError
    @gone = true
  end
app/helpers/webfinger_helper.rb
module WebfingerHelper
  def webfinger!(uri)
    Webfinger.new(uri).perform
  end
end
app/lib/webfinger.rb
  def initialize(uri)
    _, @domain = uri.split('@')

    raise ArgumentError, 'Webfinger requested for local account' if @domain.nil?

    @uri = uri
  end

  def perform
    Response.new(@uri, body_from_webfinger)
  rescue Oj::ParseError
    raise Webfinger::Error, "Invalid JSON in response for #{@uri}"
  rescue Addressable::URI::InvalidURIError
    raise Webfinger::Error, "Invalid URI for #{@uri}"
  end

  private

  def body_from_webfinger(url = standard_url, use_fallback = true)
    webfinger_request(url).perform do |res|
      if res.code == 200
        body = res.body_with_limit
        raise Webfinger::Error, "Request for #{@uri} returned empty response" if body.empty?

        body
      elsif res.code == 404 && use_fallback
        body_from_host_meta
      elsif res.code == 410
        raise Webfinger::GoneError, "#{@uri} is gone from the server"
      else
        raise Webfinger::Error, "Request for #{@uri} returned HTTP #{res.code}"
      end
    end
  end

  def webfinger_request(url)
    Request.new(:get, url).add_headers('Accept' => 'application/jrd+json, application/json')
  end

  def standard_url
    if @domain.end_with? '.onion'
      "http://#{@domain}/.well-known/webfinger?resource=#{@uri}"
    else
      "https://#{@domain}/.well-known/webfinger?resource=#{@uri}"
    end
  end

レスポンスの例

$ curl -s -H 'Accept: applicatio/jrd+json, application/json' https://mastodon.zunda.ninja/.well-known/webfinger?resource=acct:zundan@mastodon.zunda.ninja | jq
{
  "subject": "acct:zundan@mastodon.zunda.ninja",
  "aliases": [
    "https://mastodon.zunda.ninja/@zundan",
    "https://mastodon.zunda.ninja/users/zundan"
  ],
  "links": [
    {
      "rel": "http://webfinger.net/rel/profile-page",
      "type": "text/html",
      "href": "https://mastodon.zunda.ninja/@zundan"
    },
    {
      "rel": "self",
      "type": "application/activity+json",
      "href": "https://mastodon.zunda.ninja/users/zundan"
    },
    {
      "rel": "http://ostatus.org/schema/1.0/subscribe",
      "template": "https://mastodon.zunda.ninja/authorize_interaction?uri={uri}"
    },
    {
      "rel": "http://webfinger.net/rel/avatar",
      "type": "image/gif",
      "href": "https://s3.amazonaws.com/zundan-mastodon/accounts/avatars/000/000/001/original/527fd226e51be531.gif"
    }
  ]
}
zundazunda

メソッドの呼び出し順のメモ

  • ActivityPub::InboxesControlle#create
    • ActivityPub::InboxesController#process_payload
      • signed_request_actor (app/controllers/concerns/signature_verification.rb)
        • actor = actor_from_key_id(signature_params['keyId'])
          • stoplight_wrap_request { ResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false) }
            • @account ||= Account.find_remote(@username, @domain)
            • return @account if @account&.local? || @domain.nil? || !webfinger_update_due?
            • process_webfinger!(@uri)
              • webfinger!("acct:#{uri}")
                • Webfinger.new(uri).perform
                  • Response.new(@uri, body_from_webfinger) (app/lib/webfinger)
                    • webfinger_request(url)
                      • Request.new(:get, url) 必要な場合にはActivityPubオブジェクトの到着と同期的にActivityPubオブジェクトの送り主にWebFingerが送られるようだ
            • fetch_account!
              • @account = ActivityPub::FetchRemoteAccountService.new.call(actor_url, suppress_errors: @options[:suppress_errors])
                • fetch_resource(uri, id)
                  • json = fetch_resource_without_id_validation(uri, on_behalf_of)
                    • build_request(uri, on_behalf_of).perform
                      • Request.new(:get, uri)
                • ActivityPub::ProcessAccountService.new.call(@username, @domain, @json, only_key: only_key, verified_webfinger: !only_key, request_id: request_id)
                  • TODO
        • actor = stoplight_wrap_request { actor_refresh_key!(actor) }
      • ActivityPub::ProcessingWorker.perform_async (以下はActivityPubオブジェクトの到着とは非同期的)