diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 4b2549ba9..eb732008d 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -45,6 +45,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity @unresolved_mentions = [] @silenced_account_ids = [] @params = {} + @quote = nil + @quote_uri = nil process_status_params process_tags @@ -55,6 +57,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity attach_tags(@status) attach_mentions(@status) attach_counts(@status) + attach_quote(@status) end resolve_thread(@status) @@ -189,6 +192,14 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end end + def attach_quote(status) + return if @quote.nil? + + @quote.status = status + ActivityPub::VerifyQuoteService.new.call(@quote, expected_quoted_uri: @quote_uri) + @quote.save + end + def process_tags return if @object['tag'].nil? @@ -199,6 +210,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity process_mention tag elsif equals_or_includes?(tag['type'], 'Emoji') process_emoji tag + elsif equals_or_includes?(tag['type'], 'Link') + process_link tag end end end @@ -246,6 +259,19 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end end + def process_link(tag) + return if tag['href'].blank? || @quote.present? + + if tag['mediaType'] == 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' && tag['rel'] == 'https://misskey-hub.net/ns#_misskey_quote' + @quote = Quote.new(account: @account, approval_uri: tag['approvedBy']) + @quote_uri = tag['href'] + + # TODO: error handling + @quote.status = ActivityPub::TagManager.instance.uri_to_resource(tag['href'], Status) + @quote.status ||= ActivityPub::FetchRemoteStatusService.new.call(tag['href'], on_behalf_of: @account.followers.local.first, request_id: @options[:request_id]) + end + end + def process_attachments return [] if @object['attachment'].nil? diff --git a/app/models/quote.rb b/app/models/quote.rb index 07d5728fe..00fdf0974 100644 --- a/app/models/quote.rb +++ b/app/models/quote.rb @@ -30,6 +30,18 @@ class Quote < ApplicationRecord validates :activity_uri, presence: true, if: -> { account.local? && quoted_account&.remote? } + def accept! + update(state: :accepted) + end + + def reject! + if accepted? + update(state: :revoked) + elsif !revoked? + update(state: :rejected) + end + end + private def set_accounts! diff --git a/app/services/activitypub/verify_quote_service.rb b/app/services/activitypub/verify_quote_service.rb new file mode 100644 index 000000000..a3e088297 --- /dev/null +++ b/app/services/activitypub/verify_quote_service.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +class ActivityPub::VerifyQuoteService < BaseService + include JsonLdHelper + + def call(quote, expected_quoted_uri: nil, prefetched_body: nil) + @quote = quote + return if fast_track_approval! || quote.approval_uri.blank? + + @json = fetch_approval_object(quote.approval_uri, prefetched_body:) + if @json.nil? + # It is possible the proof isn't dereferenceable yet, so only reject if already accepted + quote.reject! unless @quote.pending? + return + end + + return if non_matching_uri_hosts?(quote.approval_uri, value_or_id(@json['attributedTo'])) + return unless matching_type? && matching_quote_uri? + + # Opportunistically import embedded posts if needed + return if import_quoted_post_if_needed!(expected_quoted_uri) && fast_track_approval! + + return unless matching_quoted_post? && matching_quoted_author? + + quote.accept! + end + + private + + # FEP-044f defines rules that don't require the approval flow + def fast_track_approval! + return false if @quote.quoted_status_id.blank? + + # Always allow someone to quote themselves + if @quote.account_id == @quote.quoted_account_id + @quote.accept! + + true + end + + # Always allow someone to quote posts in which they are mentioned + if @quote.quoted_status.active_mentions.exists?(mentions: { account_id: @quote.account_id }) + @quote.accept! + + true + else + false + end + end + + def fetch_approval_object(uri, prefetched_body: nil) + if prefetched_body.nil? + fetch_resource(uri, true, @quote.account, raise_on_error: :temporary) + else + body_to_json(prefetched_body, compare_id: uri) + end + end + + def matching_type? + supported_context?(@json) && equals_or_includes?(@json['type'], 'QuoteAuthorization') + end + + def matching_quote_uri? + ActivityPub::TagManager.instance.uri_for(@quote.status) == value_or_id(@json['interactingObject']) + end + + def import_quoted_post_if_needed!(uri) + return if uri.nil? || @quote.quoted_status_id.present? + return if value_or_id(@json['interactionTarget']) == uri + + status = ActivityPub::TagManager.instance.uri_to_resource(uri, Status) + # TODO: import embedded post + + if status.present? + quote.update(status: status) + true + else + false + end + end + + def matching_quoted_post? + return false if @quote.quoted_status_id.blank? + + ActivityPub::TagManager.instance.uri_for(@quote.quoted_status) == value_or_id(@json['interactionTarget']) + end + + def matching_quoted_author? + ActivityPub::TagManager.instance.uri_for(@quote.quoted_account) == value_or_id(@json['attributedTo']) + end +end diff --git a/spec/services/activitypub/verify_quote_service_spec.rb b/spec/services/activitypub/verify_quote_service_spec.rb new file mode 100644 index 000000000..30104a754 --- /dev/null +++ b/spec/services/activitypub/verify_quote_service_spec.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# TODO: high-level tests +# - fetch unapproved post with a posteriori approval +# - fetch implicitly approved post +# - handle explicit revocation (`Delete`) +# - handle post getting unapproved (`Update`) + +RSpec.describe ActivityPub::VerifyQuoteService do + subject { described_class.new } + + let(:account) { Fabricate(:account, domain: 'a.example.com') } + let(:quoted_account) { Fabricate(:account, domain: 'b.example.com') } + let(:quoted_status) { Fabricate(:status, account: quoted_account) } + let(:status) { Fabricate(:status, account: account) } + let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri) } + + context 'with an approval URI' do + let(:approval_uri) { 'https://b.example.com/approvals/1234' } + + let(:approval_type) { 'QuoteAuthorization' } + let(:approval_id) { approval_uri } + let(:approval_attributed_to) { ActivityPub::TagManager.instance.uri_for(quoted_account) } + let(:approval_interacting_object) { ActivityPub::TagManager.instance.uri_for(status) } + let(:approval_interaction_target) { ActivityPub::TagManager.instance.uri_for(quoted_status) } + + let(:json) do + { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + { + toot: 'http://joinmastodon.org/ns#', + QuoteAuthorization: 'toot:QuoteAuthorization', + gts: 'https://gotosocial.org/ns#', + interactionPolicy: { + '@id': 'gts:interactionPolicy', + '@type': '@id', + }, + interactingObject: { + '@id': 'gts:interactingObject', + '@type': '@id', + }, + interactionTarget: { + '@id': 'gts:interactionTarget', + '@type': '@id', + }, + }, + ], + type: approval_type, + id: approval_id, + attributedTo: approval_attributed_to, + interactingObject: approval_interacting_object, + interactionTarget: approval_interaction_target, + }.with_indifferent_access + end + + before do + stub_request(:get, approval_uri) + .to_return(status: 200, body: Oj.dump(json), headers: { 'Content-Type': 'application/activity+json' }) + end + + context 'with a valid activity for already-fetched posts' do + it 'updates the status' do + expect { subject.call(quote) } + .to change(quote, :state).to('accepted') + + expect(a_request(:get, approval_uri)) + .to have_been_made.once + end + end + + context 'with a valid activity for already-fetched posts, with a pre-fetched approval' do + it 'updates the status without fetching the activity' do + expect { subject.call(quote, prefetched_body: Oj.dump(json)) } + .to change(quote, :state).to('accepted') + + expect(a_request(:get, approval_uri)) + .to_not have_been_made + end + end + + context 'with an unverifiable approval' do + let(:approval_uri) { 'https://evil.com/approvals/1234' } + + it 'does not update the status' do + expect { subject.call(quote) } + .to_not change(quote, :state) + end + end + + context 'with a mismatched ID' do + let(:approval_id) { 'https://evil.com/approvals/1234' } + + it 'does not update the status' do + expect { subject.call(quote) } + .to_not change(quote, :state) + end + end + + context 'with an approval from the wrong account' do + let(:approval_attributed_to) { ActivityPub::TagManager.instance.uri_for(Fabricate(:account, domain: 'b.example.com')) } + + it 'does not update the status' do + expect { subject.call(quote) } + .to_not change(quote, :state) + end + end + + context 'with an approval for the wrong quoted post' do + let(:approval_interaction_target) { ActivityPub::TagManager.instance.uri_for(Fabricate(:status, account: quoted_account)) } + + it 'does not update the status' do + expect { subject.call(quote) } + .to_not change(quote, :state) + end + end + + context 'with an approval for the wrong quote post' do + let(:approval_interacting_object) { ActivityPub::TagManager.instance.uri_for(Fabricate(:status, account: account)) } + + it 'does not update the status' do + expect { subject.call(quote) } + .to_not change(quote, :state) + end + end + + context 'with an approval of the wrong type' do + let(:approval_type) { 'ReplyAuthorization' } + + it 'does not update the status' do + expect { subject.call(quote) } + .to_not change(quote, :state) + end + end + end + + context 'with fast-track authorizations' do + let(:approval_uri) { nil } + + context 'without any fast-track condition' do + it 'does not update the status' do + expect { subject.call(quote) } + .to_not change(quote, :state) + end + end + + context 'when the account and the quoted account are the same' do + let(:quoted_account) { account } + + it 'updates the status' do + expect { subject.call(quote) } + .to change(quote, :state).to('accepted') + end + end + + context 'when the account is mentioned by the quoted post' do + before do + quoted_status.mentions << Mention.new(account: account) + end + + it 'updates the status' do + expect { subject.call(quote) } + .to change(quote, :state).to('accepted') + end + end + end +end