[WiP] Process incoming quote posts

This commit is contained in:
Claire 2025-01-20 17:25:16 +01:00
parent ee49cbd492
commit 009b877023
4 changed files with 298 additions and 0 deletions

View File

@ -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?

View File

@ -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!

View File

@ -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

View File

@ -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