Add support for local quote stamps (#35626)
Some checks failed
Bundler Audit / security (push) Has been cancelled
Check i18n / check-i18n (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (ruby) (push) Has been cancelled
Check formatting / lint (push) Has been cancelled
Haml Linting / lint (push) Has been cancelled
Ruby Linting / lint (push) Has been cancelled
Historical data migration test / test (14-alpine) (push) Has been cancelled
Historical data migration test / test (15-alpine) (push) Has been cancelled
Historical data migration test / test (16-alpine) (push) Has been cancelled
Historical data migration test / test (17-alpine) (push) Has been cancelled
Ruby Testing / build (production) (push) Has been cancelled
Ruby Testing / build (test) (push) Has been cancelled
Ruby Testing / test (.ruby-version) (push) Has been cancelled
Ruby Testing / test (3.2) (push) Has been cancelled
Ruby Testing / test (3.3) (push) Has been cancelled
Ruby Testing / ImageMagick tests (.ruby-version) (push) Has been cancelled
Ruby Testing / ImageMagick tests (3.2) (push) Has been cancelled
Ruby Testing / ImageMagick tests (3.3) (push) Has been cancelled
Ruby Testing / End to End testing (.ruby-version) (push) Has been cancelled
Ruby Testing / End to End testing (3.2) (push) Has been cancelled
Ruby Testing / End to End testing (3.3) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled

This commit is contained in:
Claire 2025-08-01 16:55:25 +02:00 committed by GitHub
parent 483da67204
commit 591df1f205
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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' },
'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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

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
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) }

View File

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