Add initial support for ingesting and verifying remote quote posts (#34370)

This commit is contained in:
Claire 2025-04-17 09:45:23 +02:00 committed by GitHub
parent a324edabdf
commit df2611a10f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1643 additions and 22 deletions

View file

@ -45,9 +45,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@unresolved_mentions = []
@silenced_account_ids = []
@params = {}
@quote = nil
@quote_uri = nil
process_status_params
process_tags
process_quote
process_audience
ApplicationRecord.transaction do
@ -55,6 +58,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 +193,16 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
def attach_quote(status)
return if @quote.nil?
@quote.status = status
@quote.save
ActivityPub::VerifyQuoteService.new.call(@quote, fetchable_quoted_uri: @quote_uri, request_id: @options[:request_id])
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, @quote.id, @quote_uri, { 'request_id' => @options[:request_id] })
end
def process_tags
return if @object['tag'].nil?
@ -203,6 +217,17 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
def process_quote
return unless Mastodon::Feature.inbound_quotes_enabled?
@quote_uri = @status_parser.quote_uri
return if @quote_uri.blank?
approval_uri = @status_parser.quote_approval_uri
approval_uri = nil if unsupported_uri_scheme?(approval_uri)
@quote = Quote.new(account: @account, approval_uri: approval_uri)
end
def process_hashtag(tag)
return if tag['name'].blank?

View file

@ -5,7 +5,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
if @account.uri == object_uri
delete_person
else
delete_note
delete_object
end
end
@ -17,7 +17,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
end
end
def delete_note
def delete_object
return if object_uri.nil?
with_redis_lock("delete_status_in_progress:#{object_uri}", raise_on_failure: false) do
@ -32,21 +32,38 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
Tombstone.find_or_create_by(uri: object_uri, account: @account)
end
@status = Status.find_by(uri: object_uri, account: @account)
@status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
return if @status.nil?
forwarder.forward! if forwarder.forwardable?
delete_now!
case @object['type']
when 'QuoteAuthorization'
revoke_quote
when 'Note', 'Question'
delete_status
else
delete_status || revoke_quote
end
end
end
def delete_status
@status = Status.find_by(uri: object_uri, account: @account)
@status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
return if @status.nil?
forwarder.forward! if forwarder.forwardable?
RemoveStatusService.new.call(@status, redraft: false)
true
end
def revoke_quote
@quote = Quote.find_by(approval_uri: object_uri, quoted_account: @account)
return if @quote.nil?
ActivityPub::Forwarder.new(@account, @json, @quote.status).forward!
@quote.reject!
end
def forwarder
@forwarder ||= ActivityPub::Forwarder.new(@account, @json, @status)
end
def delete_now!
RemoveStatusService.new.call(@status, redraft: false)
end
end

View file

@ -101,6 +101,16 @@ class ActivityPub::Parser::StatusParser
@object.dig(:shares, :totalItems)
end
def quote_uri
%w(quote _misskey_quote quoteUrl quoteUri).filter_map do |key|
value_or_id(as_array(@object[key]).first)
end.first
end
def quote_approval_uri
as_array(@object['quoteAuthorization']).first
end
private
def raw_language_code

View file

@ -71,6 +71,23 @@ class StatusCacheHydrator
payload[:bookmarked] = Bookmark.exists?(account_id: account_id, status_id: status.id)
payload[:pinned] = StatusPin.exists?(account_id: account_id, status_id: status.id) if status.account_id == account_id
payload[:filtered] = mapped_applied_custom_filter(account_id, status)
payload[:quote] = hydrate_quote_payload(payload[:quote], status.quote, account_id) if payload[:quote]
end
def hydrate_quote_payload(empty_payload, quote, account_id)
# TODO: properly handle quotes, including visibility and access control
empty_payload.tap do |payload|
# Nothing to do if we're in the shallow (depth limit) case
next unless payload.key?(:quoted_status)
# TODO: handle hiding a rendered status or showing a non-rendered status according to visibility
if quote&.quoted_status.nil?
payload[:quoted_status] = nil
elsif payload[:quoted_status].present?
payload[:quoted_status] = StatusCacheHydrator.new(quote.quoted_status).hydrate(account_id)
end
end
end
def mapped_applied_custom_filter(account_id, status)

View file

@ -25,6 +25,7 @@ module Status::SnapshotConcern
poll_options: preloadable_poll&.options&.dup,
account_id: account_id || self.account_id,
created_at: at_time || edited_at,
quote_id: quote&.id,
rate_limit: rate_limit
)
end

67
app/models/quote.rb Normal file
View file

@ -0,0 +1,67 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: quotes
#
# id :bigint(8) not null, primary key
# activity_uri :string
# approval_uri :string
# state :integer default("pending"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint(8) not null
# quoted_account_id :bigint(8)
# quoted_status_id :bigint(8)
# status_id :bigint(8) not null
#
class Quote < ApplicationRecord
BACKGROUND_REFRESH_INTERVAL = 1.week.freeze
REFRESH_DEADLINE = 6.hours
enum :state,
{ pending: 0, accepted: 1, rejected: 2, revoked: 3 },
validate: true
belongs_to :status
belongs_to :quoted_status, class_name: 'Status', optional: true
belongs_to :account
belongs_to :quoted_account, class_name: 'Account', optional: true
before_validation :set_accounts
validates :activity_uri, presence: true, if: -> { account.local? && quoted_account&.remote? }
validate :validate_visibility
def accept!
update!(state: :accepted)
end
def reject!
if accepted?
update!(state: :revoked)
elsif !revoked?
update!(state: :rejected)
end
end
def schedule_refresh_if_stale!
return unless quoted_status_id.present? && approval_uri.present? && updated_at <= BACKGROUND_REFRESH_INTERVAL.ago
ActivityPub::QuoteRefreshWorker.perform_in(rand(REFRESH_DEADLINE), id)
end
private
def set_accounts
self.account = status.account
self.quoted_account = quoted_status&.account
end
def validate_visibility
return if account_id == quoted_account_id || quoted_status.nil? || quoted_status.distributable?
errors.add(:quoted_status_id, :visibility_mismatch)
end
end

View file

@ -93,6 +93,7 @@ class Status < ApplicationRecord
has_one :status_stat, inverse_of: :status, dependent: nil
has_one :poll, inverse_of: :status, dependent: :destroy
has_one :trend, class_name: 'StatusTrend', inverse_of: :status, dependent: nil
has_one :quote, inverse_of: :status, dependent: :destroy
validates :uri, uniqueness: true, presence: true, unless: :local?
validates :text, presence: true, unless: -> { with_media? || reblog? }
@ -154,16 +155,18 @@ class Status < ApplicationRecord
:status_stat,
:tags,
:preloadable_poll,
quote: { status: { account: [:account_stat, user: :role] } },
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
account: [:account_stat, user: :role],
active_mentions: :account,
reblog: [
:application,
:tags,
:media_attachments,
:conversation,
:status_stat,
:tags,
:preloadable_poll,
quote: { status: { account: [:account_stat, user: :role] } },
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
account: [:account_stat, user: :role],
active_mentions: :account,

View file

@ -15,6 +15,7 @@
# media_descriptions :text is an Array
# poll_options :string is an Array
# sensitive :boolean
# quote_id :bigint(8)
#
class StatusEdit < ApplicationRecord

View file

@ -16,11 +16,11 @@ class StatusRelationshipsPresenter
@filters_map = {}
else
statuses = statuses.compact
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
conversation_ids = statuses.filter_map(&:conversation_id).uniq
pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && PINNABLE_VISIBILITIES.include?(s.visibility) }
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id, s.proper.quote&.quoted_status_id] }.uniq.compact
conversation_ids = statuses.flat_map { |s| [s.proper.conversation_id, s.proper.quote&.quoted_status&.conversation_id] }.uniq.compact
pinnable_status_ids = statuses.flat_map { |s| [s.proper, s.proper.quote&.quoted_status] }.compact.filter_map { |s| s.id if s.account_id == current_account_id && PINNABLE_VISIBILITIES.include?(s.visibility) }
@filters_map = build_filters_map(statuses, current_account_id).merge(options[:filters_map] || {})
@filters_map = build_filters_map(statuses.flat_map { |s| [s, s.proper.quote&.quoted_status] }.compact.uniq, current_account_id).merge(options[:filters_map] || {})
@reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
@favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
@bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
class REST::BaseQuoteSerializer < ActiveModel::Serializer
attributes :state
def state
return object.state unless object.accepted?
# Extra states when a status is unavailable
return 'deleted' if object.quoted_status.nil?
return 'unauthorized' if status_filter.filtered?
object.state
end
def quoted_status
object.quoted_status if object.accepted? && object.quoted_status.present? && !status_filter.filtered?
end
private
def status_filter
@status_filter ||= StatusFilter.new(object.quoted_status, current_user&.account, instance_options[:relationships] || {})
end
end

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
class REST::QuoteSerializer < REST::BaseQuoteSerializer
has_one :quoted_status, serializer: REST::ShallowStatusSerializer
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class REST::ShallowQuoteSerializer < REST::BaseQuoteSerializer
attribute :quoted_status_id
def quoted_status_id
quoted_status&.id&.to_s
end
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class REST::ShallowStatusSerializer < REST::StatusSerializer
has_one :quote, key: :quote, serializer: REST::ShallowQuoteSerializer
# It looks like redefining one `has_one` requires redefining all inherited ones
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
end

View file

@ -10,6 +10,8 @@ class REST::StatusEditSerializer < ActiveModel::Serializer
has_many :ordered_media_attachments, key: :media_attachments, serializer: REST::MediaAttachmentSerializer
has_many :emojis, serializer: REST::CustomEmojiSerializer
has_one :quote, serializer: REST::QuoteSerializer, if: -> { object.quote_id.present? }
attribute :poll, if: -> { object.poll_options.present? }
def content
@ -19,4 +21,8 @@ class REST::StatusEditSerializer < ActiveModel::Serializer
def poll
{ options: object.poll_options.map { |title| { title: title } } }
end
def quote
object.quote_id == status.quote&.id ? status.quote : Quote.new(state: :pending)
end
end

View file

@ -29,6 +29,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
has_many :tags
has_many :emojis, serializer: REST::CustomEmojiSerializer
has_one :quote, key: :quote, serializer: REST::QuoteSerializer
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer

View file

@ -16,6 +16,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
@account = status.account
@media_attachments_changed = false
@poll_changed = false
@quote_changed = false
@request_id = request_id
# Only native types can be updated at the moment
@ -158,7 +159,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
@status.sensitive = @account.sensitized? || @status_parser.sensitive || false
@status.language = @status_parser.language
@significant_changes = text_significantly_changed? || @status.spoiler_text_changed? || @media_attachments_changed || @poll_changed
@significant_changes = text_significantly_changed? || @status.spoiler_text_changed? || @media_attachments_changed || @poll_changed || @quote_changed
@status.edited_at = @status_parser.edited_at if significant_changes?
@ -183,6 +184,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
update_tags!
update_mentions!
update_emojis!
update_quote!
end
def update_tags!
@ -262,6 +264,45 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
end
end
def update_quote!
return unless Mastodon::Feature.inbound_quotes_enabled?
quote = nil
quote_uri = @status_parser.quote_uri
if quote_uri.present?
approval_uri = @status_parser.quote_approval_uri
approval_uri = nil if unsupported_uri_scheme?(approval_uri)
if @status.quote.present?
# If the quoted post has changed, discard the old object and create a new one
if @status.quote.quoted_status.present? && ActivityPub::TagManager.instance.uri_for(@status.quote.quoted_status) != quote_uri
@status.quote.destroy
quote = Quote.create(status: @status, approval_uri: approval_uri)
@quote_changed = true
else
quote = @status.quote
quote.update(approval_uri: approval_uri, state: :pending) if quote.approval_uri != @status_parser.quote_approval_uri
end
else
quote = Quote.create(status: @status, approval_uri: approval_uri)
@quote_changed = true
end
end
if quote.present?
begin
quote.save
ActivityPub::VerifyQuoteService.new.call(quote, fetchable_quoted_uri: quote_uri, request_id: @request_id)
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, quote.id, quote_uri, { 'request_id' => @request_id })
end
elsif @status.quote.present?
@status.quote.destroy!
@quote_changed = true
end
end
def update_counts!
likes = @status_parser.favourites_count
shares = @status_parser.reblogs_count

View file

@ -0,0 +1,112 @@
# frozen_string_literal: true
class ActivityPub::VerifyQuoteService < BaseService
include JsonLdHelper
# Optionally fetch quoted post, and verify the quote is authorized
def call(quote, fetchable_quoted_uri: nil, prefetched_body: nil, request_id: nil)
@request_id = request_id
@quote = quote
@fetching_error = nil
fetch_quoted_post_if_needed!(fetchable_quoted_uri)
return if fast_track_approval! || quote.approval_uri.blank?
@json = fetch_approval_object(quote.approval_uri, prefetched_body:)
return quote.reject! if @json.nil?
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!(fetchable_quoted_uri) && fast_track_approval!
# Raise an error if we failed to fetch the status
raise @fetching_error if @quote.status.nil? && @fetching_error
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.followers.local.first, 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 fetch_quoted_post_if_needed!(uri)
return if uri.nil? || @quote.quoted_status.present?
status = ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
status ||= ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id)
@quote.update(quoted_status: status) if status.present?
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS => e
@fetching_error = e
end
def import_quoted_post_if_needed!(uri)
# No need to fetch if we already have a post
return if uri.nil? || @quote.quoted_status_id.present? || !@json['interactionTarget'].is_a?(Hash)
# NOTE: Replacing the object's context by that of the parent activity is
# not sound, but it's consistent with the rest of the codebase
object = @json['interactionTarget'].merge({ '@context' => @json['@context'] })
# It's not safe to fetch if the inlined object is cross-origin or doesn't match expectations
return if object['id'] != uri || non_matching_uri_hosts?(@quote.approval_uri, object['id'])
status = ActivityPub::FetchRemoteStatusService.new.call(object['id'], prefetched_body: object, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id)
if status.present?
@quote.update(quoted_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,15 @@
# frozen_string_literal: true
class ActivityPub::QuoteRefreshWorker
include Sidekiq::Worker
sidekiq_options queue: 'pull', retry: 3, dead: false, lock: :until_executed, lock_ttl: 1.day.to_i
def perform(quote_id)
quote = Quote.find_by(id: quote_id)
return if quote.nil? || quote.updated_at > Quote::BACKGROUND_REFRESH_INTERVAL.ago
quote.touch
ActivityPub::VerifyQuoteService.new.call(quote)
end
end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
class ActivityPub::RefetchAndVerifyQuoteWorker
include Sidekiq::Worker
include ExponentialBackoff
include JsonLdHelper
sidekiq_options queue: 'pull', retry: 3
def perform(quote_id, quoted_uri, options = {})
quote = Quote.find(quote_id)
ActivityPub::VerifyQuoteService.new.call(quote, fetchable_quoted_uri: quoted_uri, request_id: options[:request_id])
rescue ActiveRecord::RecordNotFound
# Do nothing
true
rescue Mastodon::UnexpectedResponseError => e
raise e unless response_error_unsalvageable?(e.response)
end
end