2
0

Add support for local quote stamps (#35626)

This commit is contained in:
Claire
2025-08-01 16:55:25 +02:00
committed by GitHub
parent 483da67204
commit 591df1f205
18 changed files with 241 additions and 16 deletions

View File

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

View File

@@ -39,6 +39,12 @@ module ContextHelper
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' }, 'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
'manualApproval' => { '@id' => 'gts:manualApproval', '@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 }.freeze
def full_context def full_context

View File

@@ -230,7 +230,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
return if @quote_uri.blank? return if @quote_uri.blank?
approval_uri = @status_parser.quote_approval_uri 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?) @quote = Quote.new(account: @account, approval_uri: approval_uri, legacy: @status_parser.legacy_quote?)
end end

View File

@@ -51,6 +51,13 @@ class ActivityPub::TagManager
end end
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) def key_uri_for(target)
[uri_for(target), '#main-key'].join [uri_for(target), '#main-key'].join
end end

View File

@@ -37,6 +37,7 @@ class Quote < ApplicationRecord
before_validation :set_accounts before_validation :set_accounts
before_validation :set_activity_uri, only: :create, if: -> { account.local? && quoted_account&.remote? } 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 :activity_uri, presence: true, if: -> { account.local? && quoted_account&.remote? }
validates :approval_uri, absence: true, if: -> { quoted_account&.local? }
validate :validate_visibility validate :validate_visibility
def accept! def accept!

View File

@@ -7,11 +7,11 @@ class ActivityPub::DeleteQuoteAuthorizationSerializer < ActivityPub::Serializer
attribute :virtual_object, key: :object attribute :virtual_object, key: :object
def id def id
[object.approval_uri, '#delete'].join [ActivityPub::TagManager.instance.approval_uri_for(object, check_approval: false), '#delete'].join
end end
def virtual_object def virtual_object
object.approval_uri ActivityPub::TagManager.instance.approval_uri_for(object, check_approval: false)
end end
def type def type

View File

@@ -204,7 +204,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
end end
def quote_authorization? def quote_authorization?
object.quote&.approval_uri.present? object.quote.present? && ActivityPub::TagManager.instance.approval_uri_for(object.quote).present?
end end
def quote def quote
@@ -213,8 +213,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
end end
def quote_authorization def quote_authorization
# TODO: approval of local quotes may work differently, perhaps? ActivityPub::TagManager.instance.approval_uri_for(object.quote)
object.quote.approval_uri
end end
class MediaAttachmentSerializer < ActivityPub::Serializer class MediaAttachmentSerializer < ActivityPub::Serializer

View File

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

View File

@@ -278,10 +278,10 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
return unless quote_uri.present? && @status.quote.present? return unless quote_uri.present? && @status.quote.present?
quote = @status.quote 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 = @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 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? if quote_uri.present?
approval_uri = @status_parser.quote_approval_uri 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 @status.quote.present?
# If the quoted post has changed, discard the old object and create a new one # If the quoted post has changed, discard the old object and create a new one

View File

@@ -13,6 +13,7 @@ class ActivityPub::VerifyQuoteService < BaseService
@fetching_error = nil @fetching_error = nil
fetch_quoted_post_if_needed!(fetchable_quoted_uri, prefetched_body: prefetched_quoted_object) 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? return if fast_track_approval! || quote.approval_uri.blank?
@json = fetch_approval_object(quote.approval_uri, prefetched_body: prefetched_approval) @json = fetch_approval_object(quote.approval_uri, prefetched_body: prefetched_approval)
@@ -34,6 +35,15 @@ class ActivityPub::VerifyQuoteService < BaseService
private 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 # FEP-044f defines rules that don't require the approval flow
def fast_track_approval! def fast_track_approval!
return false if @quote.quoted_status_id.blank? return false if @quote.quoted_status_id.blank?

View File

@@ -115,6 +115,7 @@ Rails.application.routes.draw do
resource :inbox, only: [:create] resource :inbox, only: [:create]
resources :collections, only: [:show] resources :collections, only: [:show]
resource :followers_synchronization, only: [:show] resource :followers_synchronization, only: [:show]
resources :quote_authorizations, only: [:show]
end end
end end

View File

@@ -888,7 +888,7 @@ RSpec.describe ActivityPub::Activity::Create do
end end
context 'with an unverifiable quote of a known post' do 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 let(:object_json) do
build_object( build_object(

View File

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

View File

@@ -7,13 +7,13 @@ RSpec.describe ActivityPub::DeleteQuoteAuthorizationSerializer do
describe 'serializing an object' do describe 'serializing an object' do
let(:status) { Fabricate(:status) } 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 it 'returns expected attributes' do
expect(subject.deep_symbolize_keys) expect(subject.deep_symbolize_keys)
.to include( .to include(
actor: eq(ActivityPub::TagManager.instance.uri_for(status.account)), 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' type: 'Delete'
) )
end end

View File

@@ -44,8 +44,7 @@ RSpec.describe ActivityPub::NoteSerializer do
context 'with a quote' do context 'with a quote' do
let(:quoted_status) { Fabricate(:status) } let(:quoted_status) { Fabricate(:status) }
let(:approval_uri) { 'https://example.com/foo/bar' } let!(:quote) { Fabricate(:quote, status: parent, quoted_status: quoted_status, state: :accepted) }
let!(:quote) { Fabricate(:quote, status: parent, quoted_status: quoted_status, approval_uri: approval_uri) }
it 'has the expected shape' do it 'has the expected shape' do
expect(subject).to include({ expect(subject).to include({
@@ -53,7 +52,7 @@ RSpec.describe ActivityPub::NoteSerializer do
'quote' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status), 'quote' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status),
'quoteUri' => 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), '_misskey_quote' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status),
'quoteAuthorization' => approval_uri, 'quoteAuthorization' => ActivityPub::TagManager.instance.approval_uri_for(quote),
}) })
end end
end end

View File

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

View File

@@ -564,6 +564,80 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
end end
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 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_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) } let(:quoted_status) { Fabricate(:status, account: quoted_account) }

View File

@@ -10,7 +10,7 @@ RSpec.describe RevokeQuoteService do
let(:status) { Fabricate(:status, account: alice) } 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 before do
hank.follow!(alice) hank.follow!(alice)