diff --git a/FEDERATION.md b/FEDERATION.md index 2819fa935a..03ea5449de 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -13,6 +13,7 @@ - [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-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 diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 4b2549ba96..23afda32cd 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -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? diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index 61f6ca6997..69b7bd0354 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -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 diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb index 3d2be3c66c..c13ed49635 100644 --- a/app/lib/activitypub/parser/status_parser.rb +++ b/app/lib/activitypub/parser/status_parser.rb @@ -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 diff --git a/app/lib/status_cache_hydrator.rb b/app/lib/status_cache_hydrator.rb index 676e9e62a0..8821c23d13 100644 --- a/app/lib/status_cache_hydrator.rb +++ b/app/lib/status_cache_hydrator.rb @@ -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) diff --git a/app/models/concerns/status/snapshot_concern.rb b/app/models/concerns/status/snapshot_concern.rb index 0289710904..269545ce8b 100644 --- a/app/models/concerns/status/snapshot_concern.rb +++ b/app/models/concerns/status/snapshot_concern.rb @@ -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 diff --git a/app/models/quote.rb b/app/models/quote.rb new file mode 100644 index 0000000000..8e21d9b481 --- /dev/null +++ b/app/models/quote.rb @@ -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 diff --git a/app/models/status.rb b/app/models/status.rb index cdff5a2ac3..f9afdcca8b 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -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, diff --git a/app/models/status_edit.rb b/app/models/status_edit.rb index 6e25a6f3bb..a64ef34905 100644 --- a/app/models/status_edit.rb +++ b/app/models/status_edit.rb @@ -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 diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index 5d53040fb2..2d95db82da 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -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] || {}) diff --git a/app/serializers/rest/base_quote_serializer.rb b/app/serializers/rest/base_quote_serializer.rb new file mode 100644 index 0000000000..0434f342c9 --- /dev/null +++ b/app/serializers/rest/base_quote_serializer.rb @@ -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 diff --git a/app/serializers/rest/quote_serializer.rb b/app/serializers/rest/quote_serializer.rb new file mode 100644 index 0000000000..6f2eede0ea --- /dev/null +++ b/app/serializers/rest/quote_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class REST::QuoteSerializer < REST::BaseQuoteSerializer + has_one :quoted_status, serializer: REST::ShallowStatusSerializer +end diff --git a/app/serializers/rest/shallow_quote_serializer.rb b/app/serializers/rest/shallow_quote_serializer.rb new file mode 100644 index 0000000000..1f5f229d43 --- /dev/null +++ b/app/serializers/rest/shallow_quote_serializer.rb @@ -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 diff --git a/app/serializers/rest/shallow_status_serializer.rb b/app/serializers/rest/shallow_status_serializer.rb new file mode 100644 index 0000000000..ca0ac8f4f4 --- /dev/null +++ b/app/serializers/rest/shallow_status_serializer.rb @@ -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 diff --git a/app/serializers/rest/status_edit_serializer.rb b/app/serializers/rest/status_edit_serializer.rb index f7a48797d1..30e318a6aa 100644 --- a/app/serializers/rest/status_edit_serializer.rb +++ b/app/serializers/rest/status_edit_serializer.rb @@ -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 diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index e108c789c7..e0761af7f2 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -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 diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index fd5a8c2d46..6a1066a05d 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -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 diff --git a/app/services/activitypub/verify_quote_service.rb b/app/services/activitypub/verify_quote_service.rb new file mode 100644 index 0000000000..0803d62d3a --- /dev/null +++ b/app/services/activitypub/verify_quote_service.rb @@ -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 diff --git a/app/workers/activitypub/quote_refresh_worker.rb b/app/workers/activitypub/quote_refresh_worker.rb new file mode 100644 index 0000000000..7dabfddc80 --- /dev/null +++ b/app/workers/activitypub/quote_refresh_worker.rb @@ -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 diff --git a/app/workers/activitypub/refetch_and_verify_quote_worker.rb b/app/workers/activitypub/refetch_and_verify_quote_worker.rb new file mode 100644 index 0000000000..0c7ecd9b2a --- /dev/null +++ b/app/workers/activitypub/refetch_and_verify_quote_worker.rb @@ -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 diff --git a/db/migrate/20250411094808_create_quotes.rb b/db/migrate/20250411094808_create_quotes.rb new file mode 100644 index 0000000000..8c830665ac --- /dev/null +++ b/db/migrate/20250411094808_create_quotes.rb @@ -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 diff --git a/db/migrate/20250411095859_add_quote_id_to_status_edit.rb b/db/migrate/20250411095859_add_quote_id_to_status_edit.rb new file mode 100644 index 0000000000..f5bb2f812a --- /dev/null +++ b/db/migrate/20250411095859_add_quote_id_to_status_edit.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index b09360ff43..6b1aa81bd0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 enable_extension "pg_catalog.plpgsql" @@ -871,6 +871,24 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_10_144908) do t.string "url" 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| t.integer "type", 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.string "poll_options", array: true t.boolean "sensitive" + t.bigint "quote_id" t.index ["account_id"], name: "index_status_edits_on_account_id" t.index ["status_id"], name: "index_status_edits_on_status_id" 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 "preview_card_trends", "preview_cards", on_delete: :cascade 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", "reports", on_delete: :cascade add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", name: "fk_bca45b75fd", on_delete: :nullify diff --git a/spec/fabricators/quote_fabricator.rb b/spec/fabricators/quote_fabricator.rb new file mode 100644 index 0000000000..c420d2720c --- /dev/null +++ b/spec/fabricators/quote_fabricator.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Fabricator(:quote) do + status { Fabricate.build(:status) } + quoted_status { Fabricate.build(:status) } + state :pending +end diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index 623a21ab53..73a814b9f2 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -7,7 +7,15 @@ RSpec.describe ActivityPub::Activity::Create 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, type: 'Create', actor: ActivityPub::TagManager.instance.uri_for(sender), @@ -879,6 +887,115 @@ RSpec.describe ActivityPub::Activity::Create do 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 let(:poll) { Fabricate(:poll, options: %w(Yellow Blue)) } let!(:local_status) { Fabricate(:status, poll: poll) } diff --git a/spec/lib/activitypub/activity/delete_spec.rb b/spec/lib/activitypub/activity/delete_spec.rb index 71977a96a2..849c7ada90 100644 --- a/spec/lib/activitypub/activity/delete_spec.rb +++ b/spec/lib/activitypub/activity/delete_spec.rb @@ -77,4 +77,61 @@ RSpec.describe ActivityPub::Activity::Delete do 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 diff --git a/spec/lib/status_cache_hydrator_spec.rb b/spec/lib/status_cache_hydrator_spec.rb index 958e2f62d7..a0a82e3923 100644 --- a/spec/lib/status_cache_hydrator_spec.rb +++ b/spec/lib/status_cache_hydrator_spec.rb @@ -40,10 +40,119 @@ RSpec.describe StatusCacheHydrator do 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 let(:reblog) { Fabricate(:status) } 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 before do FavouriteService.new.call(account, reblog) diff --git a/spec/serializers/rest/quote_serializer_spec.rb b/spec/serializers/rest/quote_serializer_spec.rb new file mode 100644 index 0000000000..999bd6d6b1 --- /dev/null +++ b/spec/serializers/rest/quote_serializer_spec.rb @@ -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 diff --git a/spec/serializers/rest/shallow_quote_serializer_spec.rb b/spec/serializers/rest/shallow_quote_serializer_spec.rb new file mode 100644 index 0000000000..32acd5f5d1 --- /dev/null +++ b/spec/serializers/rest/shallow_quote_serializer_spec.rb @@ -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 diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb index 28b7653833..b88c084b35 100644 --- a/spec/services/activitypub/process_status_update_service_spec.rb +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' RSpec.describe ActivityPub::ProcessStatusUpdateService do 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(:payload) do { @@ -435,6 +435,398 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do 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) { type: 'Note', name: name, replies: { type: 'Collection', totalItems: votes } } end diff --git a/spec/services/activitypub/verify_quote_service_spec.rb b/spec/services/activitypub/verify_quote_service_spec.rb new file mode 100644 index 0000000000..8fe114079b --- /dev/null +++ b/spec/services/activitypub/verify_quote_service_spec.rb @@ -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 diff --git a/spec/workers/activitypub/quote_refresh_worker_spec.rb b/spec/workers/activitypub/quote_refresh_worker_spec.rb new file mode 100644 index 0000000000..bcdcc0b746 --- /dev/null +++ b/spec/workers/activitypub/quote_refresh_worker_spec.rb @@ -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 diff --git a/spec/workers/activitypub/refetch_and_verify_quote_worker_spec.rb b/spec/workers/activitypub/refetch_and_verify_quote_worker_spec.rb new file mode 100644 index 0000000000..a925709885 --- /dev/null +++ b/spec/workers/activitypub/refetch_and_verify_quote_worker_spec.rb @@ -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