diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 143668aa43..ed8f4fbf0b 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -4,6 +4,7 @@ module ContextHelper NAMED_CONTEXT_MAP = { activitystreams: 'https://www.w3.org/ns/activitystreams', security: 'https://w3id.org/security/v1', + webfinger: 'https://purl.archive.org/socialweb/webfinger', }.freeze CONTEXT_EXTENSION_MAP = { diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index 9b78412887..664d8f88d7 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -4,7 +4,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer include RoutingHelper include FormattingHelper - context :security + context :security, :webfinger context_extensions :manually_approves_followers, :featured, :also_known_as, :moved_to, :property_value, :discoverable, :suspended, @@ -55,6 +55,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer ActivityPub::TagManager.instance.uri_for(object) end + def webfinger + object.local_username_and_domain + end + def type if object.instance_actor? 'Application' diff --git a/app/services/activitypub/fetch_remote_actor_service.rb b/app/services/activitypub/fetch_remote_actor_service.rb index 1fb3f45ce5..9daa7e445e 100644 --- a/app/services/activitypub/fetch_remote_actor_service.rb +++ b/app/services/activitypub/fetch_remote_actor_service.rb @@ -27,11 +27,23 @@ class ActivityPub::FetchRemoteActorService < BaseService 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? + raise Error, "Actor #{uri} has neither 'preferredUsername' nor `webfinger`, which is a requirement for Mastodon compatibility" if @json['preferredUsername'].blank? && @json['webfinger'].blank? - @uri = @json['id'] - @username = @json['preferredUsername'] - @domain = Addressable::URI.parse(@uri).normalized_host + @uri = @json['id'] + + # FEP-2c59 defines a `webfinger` attribute that makes things more explicit and spares an extra request in some cases. + # It supersedes `preferredUsername`. + if @json['webfinger'].present? && @json['webfinger'].is_a?(String) + @username, @domain = split_acct(@json['webfinger']) + Rails.logger.debug { "Actor #{uri} has an invalid `webfinger` value, falling back to `preferredUsername`" } + end + + if @username.blank? || @domain.blank? + raise "Actor #{uri} has no `preferredUsername`, and either a bogus or missing `webfinger`, which is a requirement for Mastodon compatibility" if @json['preferredUsername'].blank? + + @username = @json['preferredUsername'] + @domain = Addressable::URI.parse(@uri).normalized_host + end check_webfinger! unless only_key diff --git a/config/initializers/json_ld_webfinger.rb b/config/initializers/json_ld_webfinger.rb new file mode 100644 index 0000000000..da2437260c --- /dev/null +++ b/config/initializers/json_ld_webfinger.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'json/ld' + +class JSON::LD::Context + add_preloaded("http://purl.archive.org/socialweb/webfinger") do + new(processingMode: "json-ld-1.0", term_definitions: { + "webfinger" => TermDefinition.new("webfinger", id: "https://purl.archive.org/socialweb/webfinger#webfinger", type_mapping: "http://www.w3.org/2001/XMLSchema#string"), + "wf" => TermDefinition.new("wf", id: "https://purl.archive.org/socialweb/webfinger#", simple: true, prefix: true), + "xsd" => TermDefinition.new("xsd", id: "http://www.w3.org/2001/XMLSchema#", simple: true, prefix: true) + }) + end + alias_preloaded("https://purl.archive.org/socialweb/webfinger", "http://purl.archive.org/socialweb/webfinger") +end diff --git a/spec/services/activitypub/fetch_remote_actor_service_spec.rb b/spec/services/activitypub/fetch_remote_actor_service_spec.rb index 61b1e15c95..a014e234ad 100644 --- a/spec/services/activitypub/fetch_remote_actor_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_actor_service_spec.rb @@ -133,5 +133,97 @@ RSpec.describe ActivityPub::FetchRemoteActorService do expect(subject.call('https://fake.address/@foo', prefetched_body: actor.to_json)).to be_nil end end + + context 'when the actor uses the webfinger propery from FEP-2c59' do + before do + actor[:webfinger] = acct + end + + context 'when URI and WebFinger share the same host' do + let(:acct) { 'alice@example.com' } + let!(:webfinger) { { subject: "acct:#{acct}", links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } } + + before do + stub_request(:get, 'https://example.com/alice').to_return(body: actor.to_json, headers: { 'Content-Type': 'application/activity+json' }) + stub_request(:get, "https://example.com/.well-known/webfinger?resource=acct:#{acct}").to_return(body: webfinger.to_json, headers: { 'Content-Type': 'application/jrd+json' }) + end + + it 'fetches resource and looks up webfinger and sets values' do + account + + expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once + expect(a_request(:get, "https://example.com/.well-known/webfinger?resource=acct:#{acct}")).to have_been_made.once + + expect(account.username).to eq 'alice' + expect(account.domain).to eq 'example.com' + end + + it_behaves_like 'sets profile data' + end + + context 'when WebFinger returns a different URI' do + let(:acct) { 'alice@example.com' } + let!(:webfinger) { { subject: "acct:#{acct}", links: [{ rel: 'self', href: 'https://example.com/bob', type: 'application/activity+json' }] } } + + before do + stub_request(:get, 'https://example.com/alice').to_return(body: actor.to_json, headers: { 'Content-Type': 'application/activity+json' }) + stub_request(:get, "https://example.com/.well-known/webfinger?resource=acct:#{acct}").to_return(body: webfinger.to_json, headers: { 'Content-Type': 'application/jrd+json' }) + end + + it 'fetches resource and looks up webfinger and does not create account' do + expect(account).to be_nil + + expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once + expect(a_request(:get, "https://example.com/.well-known/webfinger?resource=acct:#{acct}")).to have_been_made.once + end + end + + context 'when WebFinger is at another domain' do + let(:acct) { 'alice@iscool.af' } + let!(:webfinger) { { subject: "acct:#{acct}", links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } } + + before do + stub_request(:get, 'https://example.com/alice').to_return(body: actor.to_json, headers: { 'Content-Type': 'application/activity+json' }) + stub_request(:get, "https://iscool.af/.well-known/webfinger?resource=acct:#{acct}").to_return(body: webfinger.to_json, headers: { 'Content-Type': 'application/jrd+json' }) + end + + it 'fetches resource and looks up webfinger and follows redirect and sets values' do + account + + expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once + expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to_not have_been_made + expect(a_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af')).to have_been_made.once + + expect(account.username).to eq 'alice' + expect(account.domain).to eq 'iscool.af' + end + + it_behaves_like 'sets profile data' + end + + context 'when WebFinger is at another domain and redirects back' do + let(:acct) { 'alice@iscool.af' } + let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } } + + before do + stub_request(:get, 'https://example.com/alice').to_return(body: actor.to_json, headers: { 'Content-Type': 'application/activity+json' }) + stub_request(:get, "https://iscool.af/.well-known/webfinger?resource=acct:#{acct}").to_return(body: webfinger.to_json, headers: { 'Content-Type': 'application/jrd+json' }) + stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: webfinger.to_json, headers: { 'Content-Type': 'application/jrd+json' }) + end + + it 'fetches resource and looks up webfinger and follows redirect and sets values' do + account + + expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once + expect(a_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af')).to have_been_made.once + expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made + + expect(account.username).to eq 'alice' + expect(account.domain).to eq 'example.com' + end + + it_behaves_like 'sets profile data' + end + end end end