diff --git a/app/controllers/activitypub/quote_authorizations_controller.rb b/app/controllers/activitypub/quote_authorizations_controller.rb new file mode 100644 index 00000000000..fa635d636a6 --- /dev/null +++ b/app/controllers/activitypub/quote_authorizations_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController + include Authorization + + vary_by -> { 'Signature' if authorized_fetch_mode? } + + before_action :require_account_signature!, if: :authorized_fetch_mode? + before_action :set_quote_authorization + + def show + expires_in 0, public: @quote.status.distributable? && public_fetch_mode? + render json: @quote, serializer: ActivityPub::QuoteAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + private + + def pundit_user + signed_request_account + end + + def set_quote_authorization + @quote = Quote.accepted.where(quoted_account: @account).find(params[:id]) + authorize @quote.status, :show? + rescue Mastodon::NotPermittedError + not_found + end +end diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 77ddee1122c..885f578fd0d 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -39,6 +39,12 @@ module ContextHelper 'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' }, 'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' }, }, + quote_authorizations: { + 'gts' => 'https://gotosocial.org/ns#', + 'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' }, + 'interactingObject' => { '@id' => 'gts:interactingObject' }, + 'interactionTarget' => { '@id' => 'gts:interactionTarget' }, + }, }.freeze def full_context diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 3fc9269dd3a..ab84a5dd472 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -230,7 +230,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity return if @quote_uri.blank? approval_uri = @status_parser.quote_approval_uri - approval_uri = nil if unsupported_uri_scheme?(approval_uri) + approval_uri = nil if unsupported_uri_scheme?(approval_uri) || TagManager.instance.local_url?(approval_uri) @quote = Quote.new(account: @account, approval_uri: approval_uri, legacy: @status_parser.legacy_quote?) end diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 4d83a9b8238..975763e82fe 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -51,6 +51,13 @@ class ActivityPub::TagManager end end + def approval_uri_for(quote, check_approval: true) + return quote.approval_uri unless quote.quoted_account&.local? + return if check_approval && !quote.accepted? + + account_quote_authorization_url(quote.quoted_account, quote) + end + def key_uri_for(target) [uri_for(target), '#main-key'].join end diff --git a/app/models/quote.rb b/app/models/quote.rb index 4ee5e4d41ec..a6c9dd0caca 100644 --- a/app/models/quote.rb +++ b/app/models/quote.rb @@ -37,6 +37,7 @@ class Quote < ApplicationRecord before_validation :set_accounts before_validation :set_activity_uri, only: :create, if: -> { account.local? && quoted_account&.remote? } validates :activity_uri, presence: true, if: -> { account.local? && quoted_account&.remote? } + validates :approval_uri, absence: true, if: -> { quoted_account&.local? } validate :validate_visibility def accept! diff --git a/app/serializers/activitypub/delete_quote_authorization_serializer.rb b/app/serializers/activitypub/delete_quote_authorization_serializer.rb index ab914711650..150f2a4554b 100644 --- a/app/serializers/activitypub/delete_quote_authorization_serializer.rb +++ b/app/serializers/activitypub/delete_quote_authorization_serializer.rb @@ -7,11 +7,11 @@ class ActivityPub::DeleteQuoteAuthorizationSerializer < ActivityPub::Serializer attribute :virtual_object, key: :object def id - [object.approval_uri, '#delete'].join + [ActivityPub::TagManager.instance.approval_uri_for(object, check_approval: false), '#delete'].join end def virtual_object - object.approval_uri + ActivityPub::TagManager.instance.approval_uri_for(object, check_approval: false) end def type diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 95a869658c3..972f146bafc 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -204,7 +204,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer end def quote_authorization? - object.quote&.approval_uri.present? + object.quote.present? && ActivityPub::TagManager.instance.approval_uri_for(object.quote).present? end def quote @@ -213,8 +213,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer end def quote_authorization - # TODO: approval of local quotes may work differently, perhaps? - object.quote.approval_uri + ActivityPub::TagManager.instance.approval_uri_for(object.quote) end class MediaAttachmentSerializer < ActivityPub::Serializer diff --git a/app/serializers/activitypub/quote_authorization_serializer.rb b/app/serializers/activitypub/quote_authorization_serializer.rb new file mode 100644 index 00000000000..faef3dd6866 --- /dev/null +++ b/app/serializers/activitypub/quote_authorization_serializer.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class ActivityPub::QuoteAuthorizationSerializer < ActivityPub::Serializer + include RoutingHelper + + context_extensions :quote_authorizations + + attributes :id, :type, :attributed_to, :interacting_object, :interaction_target + + def id + ActivityPub::TagManager.instance.approval_uri_for(object) + end + + def type + 'QuoteAuthorization' + end + + def attributed_to + ActivityPub::TagManager.instance.uri_for(object.quoted_account) + end + + def interaction_target + ActivityPub::TagManager.instance.uri_for(object.quoted_status) + end + + def interacting_object + ActivityPub::TagManager.instance.uri_for(object.status) + end +end diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index 064ccf0f337..0ada876d890 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -278,10 +278,10 @@ class ActivityPub::ProcessStatusUpdateService < BaseService return unless quote_uri.present? && @status.quote.present? quote = @status.quote - return if quote.quoted_status.present? && ActivityPub::TagManager.instance.uri_for(quote.quoted_status) != quote_uri + return if quote.quoted_status.present? && (ActivityPub::TagManager.instance.uri_for(quote.quoted_status) != quote_uri || quote.quoted_status.local?) approval_uri = @status_parser.quote_approval_uri - approval_uri = nil if unsupported_uri_scheme?(approval_uri) + approval_uri = nil if unsupported_uri_scheme?(approval_uri) || TagManager.instance.local_url?(approval_uri) quote.update(approval_uri: approval_uri, state: :pending, legacy: @status_parser.legacy_quote?) if quote.approval_uri != @status_parser.quote_approval_uri @@ -293,7 +293,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService if quote_uri.present? approval_uri = @status_parser.quote_approval_uri - approval_uri = nil if unsupported_uri_scheme?(approval_uri) + approval_uri = nil if unsupported_uri_scheme?(approval_uri) || TagManager.instance.local_url?(approval_uri) if @status.quote.present? # If the quoted post has changed, discard the old object and create a new one diff --git a/app/services/activitypub/verify_quote_service.rb b/app/services/activitypub/verify_quote_service.rb index 822abcf4022..2b10de9d9b3 100644 --- a/app/services/activitypub/verify_quote_service.rb +++ b/app/services/activitypub/verify_quote_service.rb @@ -13,6 +13,7 @@ class ActivityPub::VerifyQuoteService < BaseService @fetching_error = nil fetch_quoted_post_if_needed!(fetchable_quoted_uri, prefetched_body: prefetched_quoted_object) + return handle_local_quote! if quote.quoted_account&.local? return if fast_track_approval! || quote.approval_uri.blank? @json = fetch_approval_object(quote.approval_uri, prefetched_body: prefetched_approval) @@ -34,6 +35,15 @@ class ActivityPub::VerifyQuoteService < BaseService private + def handle_local_quote! + @quote.update!(approval_uri: nil) + if StatusPolicy.new(@quote.account, @quote.quoted_status).quote? + @quote.accept! + else + @quote.reject! + end + end + # FEP-044f defines rules that don't require the approval flow def fast_track_approval! return false if @quote.quoted_status_id.blank? diff --git a/config/routes.rb b/config/routes.rb index 2fff44851e0..49fcf3de792 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -115,6 +115,7 @@ Rails.application.routes.draw do resource :inbox, only: [:create] resources :collections, only: [:show] resource :followers_synchronization, only: [:show] + resources :quote_authorizations, only: [:show] end end diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index 74c9f107187..cdd5cb3194d 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -888,7 +888,7 @@ RSpec.describe ActivityPub::Activity::Create do end context 'with an unverifiable quote of a known post' do - let(:quoted_status) { Fabricate(:status) } + let(:quoted_status) { Fabricate(:status, account: Fabricate(:account, domain: 'example.com')) } let(:object_json) do build_object( diff --git a/spec/requests/activitypub/quote_authorizations_spec.rb b/spec/requests/activitypub/quote_authorizations_spec.rb new file mode 100644 index 00000000000..98daa3a79b7 --- /dev/null +++ b/spec/requests/activitypub/quote_authorizations_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'ActivityPub QuoteAuthorization endpoint' do + let(:account) { Fabricate(:account, domain: nil) } + let(:status) { Fabricate :status, account: account } + let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted) } + + before { Fabricate :favourite, status: status } + + describe 'GET /accounts/:account_username/quote_authorizations/:quote_id' do + context 'with an accepted quote' do + it 'returns http success and activity json' do + get account_quote_authorization_url(quote.quoted_account, quote) + + expect(response) + .to have_http_status(200) + expect(response.media_type) + .to eq 'application/activity+json' + + expect(response.parsed_body) + .to include(type: 'QuoteAuthorization') + end + end + + context 'with an incorrect quote authorization URL' do + it 'returns http not found' do + get account_quote_authorization_url(quote.account, quote) + + expect(response) + .to have_http_status(404) + end + end + + context 'with a rejected quote' do + before do + quote.reject! + end + + it 'returns http not found' do + get account_quote_authorization_url(quote.quoted_account, quote) + + expect(response) + .to have_http_status(404) + end + end + end +end diff --git a/spec/serializers/activitypub/delete_quote_authorization_serializer_spec.rb b/spec/serializers/activitypub/delete_quote_authorization_serializer_spec.rb index a7609644f17..48e3a4ddf73 100644 --- a/spec/serializers/activitypub/delete_quote_authorization_serializer_spec.rb +++ b/spec/serializers/activitypub/delete_quote_authorization_serializer_spec.rb @@ -7,13 +7,13 @@ RSpec.describe ActivityPub::DeleteQuoteAuthorizationSerializer do describe 'serializing an object' do let(:status) { Fabricate(:status) } - let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted, approval_uri: "https://#{Rails.configuration.x.web_domain}/approvals/1234") } + let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted) } it 'returns expected attributes' do expect(subject.deep_symbolize_keys) .to include( actor: eq(ActivityPub::TagManager.instance.uri_for(status.account)), - object: quote.approval_uri, + object: ActivityPub::TagManager.instance.approval_uri_for(quote, check_approval: false), type: 'Delete' ) end diff --git a/spec/serializers/activitypub/note_serializer_spec.rb b/spec/serializers/activitypub/note_serializer_spec.rb index d1af3f068f5..9c898e52121 100644 --- a/spec/serializers/activitypub/note_serializer_spec.rb +++ b/spec/serializers/activitypub/note_serializer_spec.rb @@ -44,8 +44,7 @@ RSpec.describe ActivityPub::NoteSerializer do context 'with a quote' do let(:quoted_status) { Fabricate(:status) } - let(:approval_uri) { 'https://example.com/foo/bar' } - let!(:quote) { Fabricate(:quote, status: parent, quoted_status: quoted_status, approval_uri: approval_uri) } + let!(:quote) { Fabricate(:quote, status: parent, quoted_status: quoted_status, state: :accepted) } it 'has the expected shape' do expect(subject).to include({ @@ -53,7 +52,7 @@ RSpec.describe ActivityPub::NoteSerializer do 'quote' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status), 'quoteUri' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status), '_misskey_quote' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status), - 'quoteAuthorization' => approval_uri, + 'quoteAuthorization' => ActivityPub::TagManager.instance.approval_uri_for(quote), }) end end diff --git a/spec/serializers/activitypub/quote_authorization_serializer_spec.rb b/spec/serializers/activitypub/quote_authorization_serializer_spec.rb new file mode 100644 index 00000000000..6a157756934 --- /dev/null +++ b/spec/serializers/activitypub/quote_authorization_serializer_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::QuoteAuthorizationSerializer do + subject { serialized_record_json(quote, described_class, adapter: ActivityPub::Adapter) } + + describe 'serializing an object' do + let(:status) { Fabricate(:status) } + let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted) } + + it 'returns expected attributes' do + expect(subject.deep_symbolize_keys) + .to include( + attributedTo: eq(ActivityPub::TagManager.instance.uri_for(status.account)), + interactionTarget: ActivityPub::TagManager.instance.uri_for(status), + interactingObject: ActivityPub::TagManager.instance.uri_for(quote.status), + type: 'QuoteAuthorization' + ) + end + end +end diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb index a7e1b923832..74b8cef413a 100644 --- a/spec/services/activitypub/process_status_update_service_spec.rb +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -564,6 +564,80 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do end end + context 'when an approved quote of a local post gets updated through an explicit update' do + let(:quoted_account) { Fabricate(:account) } + let(:quoted_status) { Fabricate(:status, account: quoted_account, quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16) } + let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, state: :accepted) } + let(:approval_uri) { ActivityPub::TagManager.instance.approval_uri_for(quote) } + + let(:payload) do + { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + { + '@id': 'https://w3id.org/fep/044f#quote', + '@type': '@id', + }, + { + '@id': 'https://w3id.org/fep/044f#quoteAuthorization', + '@type': '@id', + }, + ], + id: 'foo', + type: 'Note', + summary: 'Show more', + content: 'Hello universe', + updated: '2021-09-08T22:39:25Z', + quote: ActivityPub::TagManager.instance.uri_for(quoted_status), + quoteAuthorization: approval_uri, + } + end + + it 'updates the quote post without changing the quote status' do + expect { subject.call(status, json, json) } + .to not_change(quote, :approval_uri) + .and not_change(quote, :state).from('accepted') + .and change(status, :text).from('Hello world').to('Hello universe') + end + end + + context 'when an unapproved quote of a local post gets updated through an explicit update and claims approval' do + let(:quoted_account) { Fabricate(:account) } + let(:quoted_status) { Fabricate(:status, account: quoted_account, quote_approval_policy: 0) } + let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, state: :rejected) } + let(:approval_uri) { ActivityPub::TagManager.instance.approval_uri_for(quote) } + + let(:payload) do + { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + { + '@id': 'https://w3id.org/fep/044f#quote', + '@type': '@id', + }, + { + '@id': 'https://w3id.org/fep/044f#quoteAuthorization', + '@type': '@id', + }, + ], + id: 'foo', + type: 'Note', + summary: 'Show more', + content: 'Hello universe', + updated: '2021-09-08T22:39:25Z', + quote: ActivityPub::TagManager.instance.uri_for(quoted_status), + quoteAuthorization: approval_uri, + } + end + + it 'updates the quote post without changing the quote status' do + expect { subject.call(status, json, json) } + .to not_change(quote, :approval_uri) + .and not_change(quote, :state).from('rejected') + .and change(status, :text).from('Hello world').to('Hello universe') + end + end + context 'when the status has an existing verified quote and removes an approval link through an explicit update' do let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') } let(:quoted_status) { Fabricate(:status, account: quoted_account) } diff --git a/spec/services/revoke_quote_service_spec.rb b/spec/services/revoke_quote_service_spec.rb index 282a589162c..c1dbcfda54e 100644 --- a/spec/services/revoke_quote_service_spec.rb +++ b/spec/services/revoke_quote_service_spec.rb @@ -10,7 +10,7 @@ RSpec.describe RevokeQuoteService do let(:status) { Fabricate(:status, account: alice) } - let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted, approval_uri: "https://#{Rails.configuration.x.web_domain}/approvals/1234") } + let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted) } before do hank.follow!(alice)