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

@ -13,6 +13,7 @@
- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md) - [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) - [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
- [FEP-5feb: Search indexing consent for actors](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md) - [FEP-5feb: Search indexing consent for actors](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md)
- [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md): partial support for incoming quote-posts
## ActivityPub in Mastodon ## ActivityPub in Mastodon

View file

@ -45,9 +45,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@unresolved_mentions = [] @unresolved_mentions = []
@silenced_account_ids = [] @silenced_account_ids = []
@params = {} @params = {}
@quote = nil
@quote_uri = nil
process_status_params process_status_params
process_tags process_tags
process_quote
process_audience process_audience
ApplicationRecord.transaction do ApplicationRecord.transaction do
@ -55,6 +58,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
attach_tags(@status) attach_tags(@status)
attach_mentions(@status) attach_mentions(@status)
attach_counts(@status) attach_counts(@status)
attach_quote(@status)
end end
resolve_thread(@status) resolve_thread(@status)
@ -189,6 +193,16 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end end
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 def process_tags
return if @object['tag'].nil? return if @object['tag'].nil?
@ -203,6 +217,17 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end end
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) def process_hashtag(tag)
return if tag['name'].blank? return if tag['name'].blank?

View file

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

View file

@ -101,6 +101,16 @@ class ActivityPub::Parser::StatusParser
@object.dig(:shares, :totalItems) @object.dig(:shares, :totalItems)
end 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 private
def raw_language_code 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[: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[: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[: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 end
def mapped_applied_custom_filter(account_id, status) def mapped_applied_custom_filter(account_id, status)

View file

@ -25,6 +25,7 @@ module Status::SnapshotConcern
poll_options: preloadable_poll&.options&.dup, poll_options: preloadable_poll&.options&.dup,
account_id: account_id || self.account_id, account_id: account_id || self.account_id,
created_at: at_time || edited_at, created_at: at_time || edited_at,
quote_id: quote&.id,
rate_limit: rate_limit rate_limit: rate_limit
) )
end 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 :status_stat, inverse_of: :status, dependent: nil
has_one :poll, inverse_of: :status, dependent: :destroy has_one :poll, inverse_of: :status, dependent: :destroy
has_one :trend, class_name: 'StatusTrend', inverse_of: :status, dependent: nil 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 :uri, uniqueness: true, presence: true, unless: :local?
validates :text, presence: true, unless: -> { with_media? || reblog? } validates :text, presence: true, unless: -> { with_media? || reblog? }
@ -154,16 +155,18 @@ class Status < ApplicationRecord
:status_stat, :status_stat,
:tags, :tags,
:preloadable_poll, :preloadable_poll,
quote: { status: { account: [:account_stat, user: :role] } },
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } }, preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
account: [:account_stat, user: :role], account: [:account_stat, user: :role],
active_mentions: :account, active_mentions: :account,
reblog: [ reblog: [
:application, :application,
:tags,
:media_attachments, :media_attachments,
:conversation, :conversation,
:status_stat, :status_stat,
:tags,
:preloadable_poll, :preloadable_poll,
quote: { status: { account: [:account_stat, user: :role] } },
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } }, preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
account: [:account_stat, user: :role], account: [:account_stat, user: :role],
active_mentions: :account, active_mentions: :account,

View file

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

View file

@ -16,11 +16,11 @@ class StatusRelationshipsPresenter
@filters_map = {} @filters_map = {}
else else
statuses = statuses.compact statuses = statuses.compact
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id, s.proper.quote&.quoted_status_id] }.uniq.compact
conversation_ids = statuses.filter_map(&:conversation_id).uniq conversation_ids = statuses.flat_map { |s| [s.proper.conversation_id, s.proper.quote&.quoted_status&.conversation_id] }.uniq.compact
pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && PINNABLE_VISIBILITIES.include?(s.visibility) } 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] || {}) @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] || {}) @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] || {}) @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 :ordered_media_attachments, key: :media_attachments, serializer: REST::MediaAttachmentSerializer
has_many :emojis, serializer: REST::CustomEmojiSerializer has_many :emojis, serializer: REST::CustomEmojiSerializer
has_one :quote, serializer: REST::QuoteSerializer, if: -> { object.quote_id.present? }
attribute :poll, if: -> { object.poll_options.present? } attribute :poll, if: -> { object.poll_options.present? }
def content def content
@ -19,4 +21,8 @@ class REST::StatusEditSerializer < ActiveModel::Serializer
def poll def poll
{ options: object.poll_options.map { |title| { title: title } } } { options: object.poll_options.map { |title| { title: title } } }
end end
def quote
object.quote_id == status.quote&.id ? status.quote : Quote.new(state: :pending)
end
end end

View file

@ -29,6 +29,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
has_many :tags has_many :tags
has_many :emojis, serializer: REST::CustomEmojiSerializer 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 :preview_card, key: :card, serializer: REST::PreviewCardSerializer
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer

View file

@ -16,6 +16,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
@account = status.account @account = status.account
@media_attachments_changed = false @media_attachments_changed = false
@poll_changed = false @poll_changed = false
@quote_changed = false
@request_id = request_id @request_id = request_id
# Only native types can be updated at the moment # 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.sensitive = @account.sensitized? || @status_parser.sensitive || false
@status.language = @status_parser.language @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? @status.edited_at = @status_parser.edited_at if significant_changes?
@ -183,6 +184,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
update_tags! update_tags!
update_mentions! update_mentions!
update_emojis! update_emojis!
update_quote!
end end
def update_tags! def update_tags!
@ -262,6 +264,45 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
end end
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! def update_counts!
likes = @status_parser.favourites_count likes = @status_parser.favourites_count
shares = @status_parser.reblogs_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

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true
class CreateQuotes < ActiveRecord::Migration[8.0]
def change
create_table :quotes do |t|
t.belongs_to :account, foreign_key: { on_delete: :cascade }, index: false, null: false
t.belongs_to :status, foreign_key: { on_delete: :cascade }, index: { unique: true }, null: false
t.belongs_to :quoted_status, foreign_key: { to_table: :statuses, on_delete: :nullify }, null: true
t.belongs_to :quoted_account, foreign_key: { to_table: :accounts, on_delete: :nullify }, null: true
t.integer :state, null: false, default: 0
t.string :approval_uri, index: { where: 'approval_uri IS NOT NULL' }
t.string :activity_uri, index: { unique: true, where: 'activity_uri IS NOT NULL' }
t.timestamps
end
# Can be used in the future to e.g. bulk-reject quotes from blocked accounts
add_index :quotes, [:account_id, :quoted_account_id]
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddQuoteIdToStatusEdit < ActiveRecord::Migration[8.0]
def change
add_column :status_edits, :quote_id, :bigint, null: true
end
end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_04_10_144908) do ActiveRecord::Schema[8.0].define(version: 2025_04_11_095859) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
@ -871,6 +871,24 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_10_144908) do
t.string "url" t.string "url"
end end
create_table "quotes", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "status_id", null: false
t.bigint "quoted_status_id"
t.bigint "quoted_account_id"
t.integer "state", default: 0, null: false
t.string "approval_uri"
t.string "activity_uri"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "quoted_account_id"], name: "index_quotes_on_account_id_and_quoted_account_id"
t.index ["activity_uri"], name: "index_quotes_on_activity_uri", unique: true, where: "(activity_uri IS NOT NULL)"
t.index ["approval_uri"], name: "index_quotes_on_approval_uri", where: "(approval_uri IS NOT NULL)"
t.index ["quoted_account_id"], name: "index_quotes_on_quoted_account_id"
t.index ["quoted_status_id"], name: "index_quotes_on_quoted_status_id"
t.index ["status_id"], name: "index_quotes_on_status_id", unique: true
end
create_table "relationship_severance_events", force: :cascade do |t| create_table "relationship_severance_events", force: :cascade do |t|
t.integer "type", null: false t.integer "type", null: false
t.string "target_name", null: false t.string "target_name", null: false
@ -1007,6 +1025,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_10_144908) do
t.text "media_descriptions", array: true t.text "media_descriptions", array: true
t.string "poll_options", array: true t.string "poll_options", array: true
t.boolean "sensitive" t.boolean "sensitive"
t.bigint "quote_id"
t.index ["account_id"], name: "index_status_edits_on_account_id" t.index ["account_id"], name: "index_status_edits_on_account_id"
t.index ["status_id"], name: "index_status_edits_on_status_id" t.index ["status_id"], name: "index_status_edits_on_status_id"
end end
@ -1350,6 +1369,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_10_144908) do
add_foreign_key "polls", "statuses", on_delete: :cascade add_foreign_key "polls", "statuses", on_delete: :cascade
add_foreign_key "preview_card_trends", "preview_cards", on_delete: :cascade add_foreign_key "preview_card_trends", "preview_cards", on_delete: :cascade
add_foreign_key "preview_cards", "accounts", column: "author_account_id", on_delete: :nullify add_foreign_key "preview_cards", "accounts", column: "author_account_id", on_delete: :nullify
add_foreign_key "quotes", "accounts", column: "quoted_account_id", on_delete: :nullify
add_foreign_key "quotes", "accounts", on_delete: :cascade
add_foreign_key "quotes", "statuses", column: "quoted_status_id", on_delete: :nullify
add_foreign_key "quotes", "statuses", on_delete: :cascade
add_foreign_key "report_notes", "accounts", on_delete: :cascade add_foreign_key "report_notes", "accounts", on_delete: :cascade
add_foreign_key "report_notes", "reports", on_delete: :cascade add_foreign_key "report_notes", "reports", on_delete: :cascade
add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", name: "fk_bca45b75fd", on_delete: :nullify add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", name: "fk_bca45b75fd", on_delete: :nullify

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
Fabricator(:quote) do
status { Fabricate.build(:status) }
quoted_status { Fabricate.build(:status) }
state :pending
end

View file

@ -7,7 +7,15 @@ RSpec.describe ActivityPub::Activity::Create do
let(:json) do let(:json) do
{ {
'@context': 'https://www.w3.org/ns/activitystreams', '@context': [
'https://www.w3.org/ns/activitystreams',
{
quote: {
'@id': 'https://w3id.org/fep/044f#quote',
'@type': '@id',
},
},
],
id: [ActivityPub::TagManager.instance.uri_for(sender), '#foo'].join, id: [ActivityPub::TagManager.instance.uri_for(sender), '#foo'].join,
type: 'Create', type: 'Create',
actor: ActivityPub::TagManager.instance.uri_for(sender), actor: ActivityPub::TagManager.instance.uri_for(sender),
@ -879,6 +887,115 @@ RSpec.describe ActivityPub::Activity::Create do
end end
end end
context 'with an unverifiable quote of a known post', feature: :inbound_quotes do
let(:quoted_status) { Fabricate(:status) }
let(:object_json) do
build_object(
type: 'Note',
content: 'woah what she said is amazing',
quote: ActivityPub::TagManager.instance.uri_for(quoted_status)
)
end
it 'creates a status with an unverified quote' do
expect { subject.perform }.to change(sender.statuses, :count).by(1)
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.quote).to_not be_nil
expect(status.quote).to have_attributes(
state: 'pending',
approval_uri: nil
)
end
end
context 'with an unverifiable unknown post', feature: :inbound_quotes do
let(:unknown_post_uri) { 'https://unavailable.example.com/unavailable-post' }
let(:object_json) do
build_object(
type: 'Note',
content: 'woah what she said is amazing',
quote: unknown_post_uri
)
end
before do
stub_request(:get, unknown_post_uri).to_return(status: 404)
end
it 'creates a status with an unverified quote' do
expect { subject.perform }.to change(sender.statuses, :count).by(1)
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.quote).to_not be_nil
expect(status.quote).to have_attributes(
state: 'pending',
approval_uri: nil
)
end
end
context 'with a verifiable quote of a known post', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let(:approval_uri) { 'https://quoted.example.com/quote-approval' }
let(:object_json) do
build_object(
type: 'Note',
content: 'woah what she said is amazing',
quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
quoteAuthorization: approval_uri
)
end
before do
stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
'@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: 'QuoteAuthorization',
id: approval_uri,
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_status.account),
interactingObject: object_json[:id],
interactionTarget: ActivityPub::TagManager.instance.uri_for(quoted_status),
}))
end
it 'creates a status with a verified quote' do
expect { subject.perform }.to change(sender.statuses, :count).by(1)
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.quote).to_not be_nil
expect(status.quote).to have_attributes(
state: 'accepted',
approval_uri: approval_uri
)
end
end
context 'when a vote to a local poll' do context 'when a vote to a local poll' do
let(:poll) { Fabricate(:poll, options: %w(Yellow Blue)) } let(:poll) { Fabricate(:poll, options: %w(Yellow Blue)) }
let!(:local_status) { Fabricate(:status, poll: poll) } let!(:local_status) { Fabricate(:status, poll: poll) }

View file

@ -77,4 +77,61 @@ RSpec.describe ActivityPub::Activity::Delete do
end end
end end
end end
context 'when the deleted object is an account' do
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Delete',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: ActivityPub::TagManager.instance.uri_for(sender),
signature: 'foo',
}.with_indifferent_access
end
describe '#perform' do
subject { described_class.new(json, sender) }
let(:service) { instance_double(DeleteAccountService, call: true) }
before do
allow(DeleteAccountService).to receive(:new).and_return(service)
end
it 'calls the account deletion service' do
subject.perform
expect(service)
.to have_received(:call).with(sender, { reserve_username: false, skip_activitypub: true })
end
end
end
context 'when the deleted object is a quote authorization' do
let(:quoter) { Fabricate(:account, domain: 'b.example.com') }
let(:status) { Fabricate(:status, account: quoter) }
let(:quoted_status) { Fabricate(:status, account: sender, uri: 'https://example.com/statuses/1234') }
let!(:quote) { Fabricate(:quote, approval_uri: 'https://example.com/approvals/1234', state: :accepted, status: status, quoted_status: quoted_status) }
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Delete',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: quote.approval_uri,
signature: 'foo',
}.with_indifferent_access
end
describe '#perform' do
subject { described_class.new(json, sender) }
it 'revokes the authorization' do
expect { subject.perform }
.to change { quote.reload.state }.to('revoked')
end
end
end
end end

View file

@ -40,10 +40,119 @@ RSpec.describe StatusCacheHydrator do
end end
end end
context 'when handling an unapproved quote' do
let(:quoted_status) { Fabricate(:status) }
before do
Fabricate(:quote, status: status, quoted_status: quoted_status, state: :pending)
end
it 'renders the same attributes as full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:quote]).to_not be_nil
end
end
context 'when handling an approved quote' do
let(:quoted_status) { Fabricate(:status) }
before do
Fabricate(:quote, status: status, quoted_status: quoted_status, state: :accepted)
end
it 'renders the same attributes as full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:quote]).to_not be_nil
end
context 'when the quoted post has been favourited' do
before do
FavouriteService.new.call(account, quoted_status)
end
it 'renders the same attributes as full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:quote]).to_not be_nil
end
end
context 'when the quoted post has been reblogged' do
before do
ReblogService.new.call(account, quoted_status)
end
it 'renders the same attributes as full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:quote]).to_not be_nil
end
end
context 'when the quoted post matches account filters' do
let(:quoted_status) { Fabricate(:status, text: 'this toot is about that banned word') }
before do
account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }])
end
it 'renders the same attributes as a full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:quote]).to_not be_nil
end
end
end
context 'when handling a reblog' do context 'when handling a reblog' do
let(:reblog) { Fabricate(:status) } let(:reblog) { Fabricate(:status) }
let(:status) { Fabricate(:status, reblog: reblog) } let(:status) { Fabricate(:status, reblog: reblog) }
context 'when the reblog has an approved quote' do
let(:quoted_status) { Fabricate(:status) }
before do
Fabricate(:quote, status: reblog, quoted_status: quoted_status, state: :accepted)
end
it 'renders the same attributes as full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:reblog][:quote]).to_not be_nil
end
context 'when the quoted post has been favourited' do
before do
FavouriteService.new.call(account, quoted_status)
end
it 'renders the same attributes as full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:reblog][:quote]).to_not be_nil
end
end
context 'when the quoted post has been reblogged' do
before do
ReblogService.new.call(account, quoted_status)
end
it 'renders the same attributes as full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:reblog][:quote]).to_not be_nil
end
end
context 'when the quoted post matches account filters' do
let(:quoted_status) { Fabricate(:status, text: 'this toot is about that banned word') }
before do
account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }])
end
it 'renders the same attributes as a full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:reblog][:quote]).to_not be_nil
end
end
end
context 'when it has been favourited' do context 'when it has been favourited' do
before do before do
FavouriteService.new.call(account, reblog) FavouriteService.new.call(account, reblog)

View file

@ -0,0 +1,87 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe REST::QuoteSerializer do
subject do
serialized_record_json(
quote,
described_class,
options: {
scope: current_user,
scope_name: :current_user,
}
)
end
let(:current_user) { Fabricate(:user) }
let(:quote) { Fabricate(:quote) }
context 'with a pending quote' do
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status: nil,
state: 'pending'
)
end
end
context 'with an accepted quote' do
let(:quote) { Fabricate(:quote, state: :accepted) }
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status: be_a(Hash),
state: 'accepted'
)
end
end
context 'with an accepted quote of a deleted post' do
let(:quote) { Fabricate(:quote, state: :accepted) }
before do
quote.quoted_status.destroy!
quote.reload
end
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status: nil,
state: 'deleted'
)
end
end
context 'with an accepted quote of a blocked user' do
let(:quote) { Fabricate(:quote, state: :accepted) }
before do
quote.quoted_account.block!(current_user.account)
end
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status: nil,
state: 'unauthorized'
)
end
end
context 'with a recursive accepted quote' do
let(:status) { Fabricate(:status) }
let(:quote) { Fabricate(:quote, status: status, quoted_status: status, state: :accepted) }
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status: be_a(Hash),
state: 'accepted'
)
end
end
end

View file

@ -0,0 +1,93 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe REST::ShallowQuoteSerializer do
subject do
serialized_record_json(
quote,
described_class,
options: {
scope: current_user,
scope_name: :current_user,
}
)
end
let(:current_user) { Fabricate(:user) }
let(:quote) { Fabricate(:quote) }
context 'with a pending quote' do
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status_id: nil,
state: 'pending'
)
expect(subject.deep_symbolize_keys)
.to_not have_key(:quoted_status)
end
end
context 'with an accepted quote' do
let(:quote) { Fabricate(:quote, state: :accepted) }
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status_id: be_a(String),
state: 'accepted'
)
expect(subject.deep_symbolize_keys)
.to_not have_key(:quoted_status)
end
end
context 'with an accepted quote of a deleted post' do
let(:quote) { Fabricate(:quote, state: :accepted) }
before do
quote.quoted_status.destroy!
quote.reload
end
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status_id: nil,
state: 'deleted'
)
end
end
context 'with an accepted quote of a blocked user' do
let(:quote) { Fabricate(:quote, state: :accepted) }
before do
quote.quoted_account.block!(current_user.account)
end
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status_id: nil,
state: 'unauthorized'
)
end
end
context 'with a recursive accepted quote' do
let(:status) { Fabricate(:status) }
let(:quote) { Fabricate(:quote, status: status, quoted_status: status, state: :accepted) }
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status_id: be_a(String),
state: 'accepted'
)
expect(subject.deep_symbolize_keys)
.to_not have_key(:quoted_status)
end
end
end

View file

@ -5,7 +5,7 @@ require 'rails_helper'
RSpec.describe ActivityPub::ProcessStatusUpdateService do RSpec.describe ActivityPub::ProcessStatusUpdateService do
subject { described_class.new } subject { described_class.new }
let!(:status) { Fabricate(:status, text: 'Hello world', account: Fabricate(:account, domain: 'example.com')) } let!(:status) { Fabricate(:status, text: 'Hello world', uri: 'https://example.com/statuses/1234', account: Fabricate(:account, domain: 'example.com')) }
let(:bogus_mention) { 'https://example.com/users/erroringuser' } let(:bogus_mention) { 'https://example.com/users/erroringuser' }
let(:payload) do let(:payload) do
{ {
@ -435,6 +435,398 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
end end
end end
context 'when the status has an existing unverified quote and adds an approval link', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: nil) }
let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
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
before do
stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
'@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: 'QuoteAuthorization',
id: approval_uri,
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_status.account),
interactingObject: ActivityPub::TagManager.instance.uri_for(status),
interactionTarget: ActivityPub::TagManager.instance.uri_for(quoted_status),
}))
end
it 'updates the approval URI and verifies the quote' do
expect { subject.call(status, json, json) }
.to change(quote, :approval_uri).to(approval_uri)
.and change(quote, :state).to('accepted')
end
end
context 'when the status has an existing verified quote and removes an approval link', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) }
let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
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),
}
end
it 'removes the approval URI and unverifies the quote' do
expect { subject.call(status, json, json) }
.to change(quote, :approval_uri).to(nil)
.and change(quote, :state).to('pending')
end
end
context 'when the status adds a verifiable quote', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
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
before do
stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
'@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: 'QuoteAuthorization',
id: approval_uri,
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_status.account),
interactingObject: ActivityPub::TagManager.instance.uri_for(status),
interactionTarget: ActivityPub::TagManager.instance.uri_for(quoted_status),
}))
end
it 'updates the approval URI and verifies the quote' do
expect { subject.call(status, json, json) }
.to change(status, :quote).from(nil)
expect(status.quote.approval_uri).to eq approval_uri
expect(status.quote.state).to eq 'accepted'
end
end
context 'when the status adds a unverifiable quote', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
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),
}
end
it 'updates the approval URI but does not verify the quote' do
expect { subject.call(status, json, json) }
.to change(status, :quote).from(nil)
expect(status.quote.approval_uri).to be_nil
expect(status.quote.state).to eq 'pending'
end
end
context 'when the status removes a verified quote', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) }
let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
let(:payload) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Note',
summary: 'Show more',
content: 'Hello universe',
updated: '2021-09-08T22:39:25Z',
}
end
it 'removes the quote' do
expect { subject.call(status, json, json) }
.to change { status.reload.quote }.to(nil)
expect { quote.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'when the status removes an unverified quote', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: nil, state: :pending) }
let(:payload) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Note',
summary: 'Show more',
content: 'Hello universe',
updated: '2021-09-08T22:39:25Z',
}
end
it 'removes the quote' do
expect { subject.call(status, json, json) }
.to change { status.reload.quote }.to(nil)
expect { quote.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'when the status swaps a verified quote with an unverifiable quote', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let(:second_quoted_status) { Fabricate(:status, account: quoted_account) }
let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) }
let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
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(second_quoted_status),
quoteAuthorization: approval_uri,
}
end
before do
stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
'@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: 'QuoteAuthorization',
id: approval_uri,
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_status.account),
interactingObject: ActivityPub::TagManager.instance.uri_for(status),
interactionTarget: ActivityPub::TagManager.instance.uri_for(quoted_status),
}))
end
it 'updates the URI and unverifies the quote' do
expect { subject.call(status, json, json) }
.to change { status.quote.quoted_status }.from(quoted_status).to(second_quoted_status)
.and change { status.quote.state }.from('accepted')
expect { quote.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'when the status swaps a verified quote with another verifiable quote', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:second_quoted_account) { Fabricate(:account, domain: 'second-quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let(:second_quoted_status) { Fabricate(:status, account: second_quoted_account) }
let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) }
let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
let(:second_approval_uri) { 'https://second-quoted.example.com/approvals/2' }
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(second_quoted_status),
quoteAuthorization: second_approval_uri,
}
end
before do
stub_request(:get, second_approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
'@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: 'QuoteAuthorization',
id: second_approval_uri,
attributedTo: ActivityPub::TagManager.instance.uri_for(second_quoted_status.account),
interactingObject: ActivityPub::TagManager.instance.uri_for(status),
interactionTarget: ActivityPub::TagManager.instance.uri_for(second_quoted_status),
}))
end
it 'updates the URI and unverifies the quote' do
expect { subject.call(status, json, json) }
.to change { status.quote.quoted_status }.from(quoted_status).to(second_quoted_status)
.and change { status.quote.approval_uri }.from(approval_uri).to(second_approval_uri)
.and(not_change { status.quote.state })
expect { quote.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
def poll_option_json(name, votes) def poll_option_json(name, votes)
{ type: 'Note', name: name, replies: { type: 'Collection', totalItems: votes } } { type: 'Note', name: name, replies: { type: 'Collection', totalItems: votes } }
end end

View file

@ -0,0 +1,246 @@
# frozen_string_literal: true
require 'rails_helper'
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 unfetchable approval URI' do
let(:approval_uri) { 'https://b.example.com/approvals/1234' }
before do
stub_request(:get, approval_uri)
.to_return(status: 404)
end
context 'with an already-fetched post' do
it 'does not update the status' do
expect { subject.call(quote) }
.to change(quote, :state).to('rejected')
end
end
context 'with an already-verified quote' do
let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) }
it 'rejects the quote' do
expect { subject.call(quote) }
.to change(quote, :state).to('revoked')
end
end
end
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 a post that cannot be fetched but is inlined' do
let(:quoted_status) { nil }
let(:approval_interaction_target) do
{
type: 'Note',
id: 'https://b.example.com/unknown-quoted',
to: 'https://www.w3.org/ns/activitystreams#Public',
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_account),
content: 'previously unknown post',
}
end
before do
stub_request(:get, 'https://b.example.com/unknown-quoted')
.to_return(status: 404)
end
it 'updates the status' do
expect { subject.call(quote, fetchable_quoted_uri: 'https://b.example.com/unknown-quoted') }
.to change(quote, :state).to('accepted')
expect(a_request(:get, approval_uri))
.to have_been_made.once
expect(quote.reload.quoted_status.content).to eq 'previously unknown post'
end
end
context 'with a valid activity for a post that cannot be fetched and is inlined from an untrusted source' do
let(:quoted_status) { nil }
let(:approval_interaction_target) do
{
type: 'Note',
id: 'https://example.com/unknown-quoted',
to: 'https://www.w3.org/ns/activitystreams#Public',
attributedTo: ActivityPub::TagManager.instance.uri_for(account),
content: 'previously unknown post',
}
end
before do
stub_request(:get, 'https://example.com/unknown-quoted')
.to_return(status: 404)
end
it 'does not update the status' do
expect { subject.call(quote, fetchable_quoted_uri: 'https://example.com/unknown-quoted') }
.to not_change(quote, :state)
.and not_change(quote, :quoted_status)
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 an invalid approval document because of a mismatched ID' do
let(:approval_id) { 'https://evil.com/approvals/1234' }
it 'does not accept the quote' do
# NOTE: maybe we want to skip that instead of rejecting it?
expect { subject.call(quote) }
.to change(quote, :state).to('rejected')
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

View file

@ -0,0 +1,44 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::QuoteRefreshWorker do
let(:worker) { described_class.new }
let(:service) { instance_double(ActivityPub::VerifyQuoteService, call: true) }
describe '#perform' do
before { stub_service }
let(:account) { Fabricate(:account, domain: 'example.com') }
let(:status) { Fabricate(:status, account: account) }
let(:quote) { Fabricate(:quote, status: status, quoted_status: nil, updated_at: updated_at) }
context 'when dealing with an old quote' do
let(:updated_at) { (Quote::BACKGROUND_REFRESH_INTERVAL * 2).ago }
it 'sends the status to the service and bumps the updated date' do
expect { worker.perform(quote.id) }
.to(change { quote.reload.updated_at })
expect(service).to have_received(:call).with(quote)
end
end
context 'when dealing with a recent quote' do
let(:updated_at) { Time.now.utc }
it 'does not call the service and does not touch the quote' do
expect { worker.perform(quote.id) }
.to_not(change { quote.reload.updated_at })
expect(service).to_not have_received(:call).with(quote)
end
end
end
def stub_service
allow(ActivityPub::VerifyQuoteService)
.to receive(:new)
.and_return(service)
end
end

View file

@ -0,0 +1,35 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::RefetchAndVerifyQuoteWorker do
let(:worker) { described_class.new }
let(:service) { instance_double(ActivityPub::VerifyQuoteService, call: true) }
describe '#perform' do
before { stub_service }
let(:account) { Fabricate(:account, domain: 'example.com') }
let(:status) { Fabricate(:status, account: account) }
let(:quote) { Fabricate(:quote, status: status, quoted_status: nil) }
let(:url) { 'https://example.com/quoted-status' }
it 'sends the status to the service' do
worker.perform(quote.id, url)
expect(service).to have_received(:call).with(quote, fetchable_quoted_uri: url, request_id: anything)
end
it 'returns nil for non-existent record' do
result = worker.perform(123_123_123, url)
expect(result).to be(true)
end
end
def stub_service
allow(ActivityPub::VerifyQuoteService)
.to receive(:new)
.and_return(service)
end
end