From 2dfdcc7dcb63e0a480e5a5b80b4f56344540d018 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 31 Jul 2025 11:36:51 +0200 Subject: [PATCH] Add API endpoints to view and revoke one's quoted posts (#35578) --- .../api/v1/statuses/quotes_controller.rb | 72 ++++++++++ app/models/quote.rb | 2 + app/policies/quote_policy.rb | 7 + app/policies/status_policy.rb | 4 + .../delete_quote_authorization_serializer.rb | 28 ++++ app/services/revoke_quote_service.rb | 32 +++++ config/routes/api.rb | 6 + spec/requests/api/v1/statuses/quotes_spec.rb | 126 ++++++++++++++++++ ...ete_quote_authorization_serializer_spec.rb | 21 +++ spec/services/revoke_quote_service_spec.rb | 26 ++++ 10 files changed, 324 insertions(+) create mode 100644 app/controllers/api/v1/statuses/quotes_controller.rb create mode 100644 app/policies/quote_policy.rb create mode 100644 app/serializers/activitypub/delete_quote_authorization_serializer.rb create mode 100644 app/services/revoke_quote_service.rb create mode 100644 spec/requests/api/v1/statuses/quotes_spec.rb create mode 100644 spec/serializers/activitypub/delete_quote_authorization_serializer_spec.rb create mode 100644 spec/services/revoke_quote_service_spec.rb diff --git a/app/controllers/api/v1/statuses/quotes_controller.rb b/app/controllers/api/v1/statuses/quotes_controller.rb new file mode 100644 index 00000000000..7dd91e9a2ee --- /dev/null +++ b/app/controllers/api/v1/statuses/quotes_controller.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :index + before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: :revoke + + before_action :check_owner! + before_action :set_quote, only: :revoke + after_action :insert_pagination_headers, only: :index + + def index + cache_if_unauthenticated! + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer + end + + def revoke + authorize @quote, :revoke? + + RevokeQuoteService.new.call(@quote) + + render_empty # TODO: do we want to return something? an updated status? + end + + private + + def check_owner! + authorize @status, :list_quotes? + end + + def set_quote + @quote = @status.quotes.find_by!(status_id: params[:id]) + end + + def load_statuses + scope = default_statuses + scope = scope.not_excluded_by_account(current_account) unless current_account.nil? + scope.merge(paginated_quotes).to_a + end + + def default_statuses + Status.includes(:quote).references(:quote) + end + + def paginated_quotes + @status.quotes.accepted.paginate_by_max_id( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def next_path + api_v1_status_quotes_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_status_quotes_url pagination_params(since_id: pagination_since_id) unless @statuses.empty? + end + + def pagination_max_id + @statuses.last.quote.id + end + + def pagination_since_id + @statuses.first.quote.id + end + + def records_continue? + @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) + end +end diff --git a/app/models/quote.rb b/app/models/quote.rb index 89845ed9f49..b844805da73 100644 --- a/app/models/quote.rb +++ b/app/models/quote.rb @@ -17,6 +17,8 @@ # status_id :bigint(8) not null # class Quote < ApplicationRecord + include Paginable + BACKGROUND_REFRESH_INTERVAL = 1.week.freeze REFRESH_DEADLINE = 6.hours diff --git a/app/policies/quote_policy.rb b/app/policies/quote_policy.rb new file mode 100644 index 00000000000..a8be64a7792 --- /dev/null +++ b/app/policies/quote_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class QuotePolicy < ApplicationPolicy + def revoke? + record.quoted_account_id.present? && record.quoted_account_id == current_account&.id + end +end diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index d9bb7201c00..5d01da42c61 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -36,6 +36,10 @@ class StatusPolicy < ApplicationPolicy owned? end + def list_quotes? + owned? + end + alias unreblog? destroy? def update? diff --git a/app/serializers/activitypub/delete_quote_authorization_serializer.rb b/app/serializers/activitypub/delete_quote_authorization_serializer.rb new file mode 100644 index 00000000000..ab914711650 --- /dev/null +++ b/app/serializers/activitypub/delete_quote_authorization_serializer.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class ActivityPub::DeleteQuoteAuthorizationSerializer < ActivityPub::Serializer + attributes :id, :type, :actor, :to + + # TODO: change the `object` to a `QuoteAuthorization` object instead of just the URI? + attribute :virtual_object, key: :object + + def id + [object.approval_uri, '#delete'].join + end + + def virtual_object + object.approval_uri + end + + def type + 'Delete' + end + + def actor + ActivityPub::TagManager.instance.uri_for(object.quoted_account) + end + + def to + [ActivityPub::TagManager::COLLECTIONS[:public]] + end +end diff --git a/app/services/revoke_quote_service.rb b/app/services/revoke_quote_service.rb new file mode 100644 index 00000000000..8f5dc8f9105 --- /dev/null +++ b/app/services/revoke_quote_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class RevokeQuoteService < BaseService + include Payloadable + + def call(quote) + @quote = quote + @account = quote.quoted_account + + @quote.reject! + distribute_stamp_deletion! + end + + private + + def distribute_stamp_deletion! + ActivityPub::DeliveryWorker.push_bulk(inboxes, limit: 1_000) do |inbox_url| + [signed_activity_json, @account.id, inbox_url] + end + end + + def inboxes + [ + @quote.status, + @quote.quoted_status, + ].compact.map { |status| StatusReachFinder.new(status, unsafe: true).inboxes }.flatten.uniq + end + + def signed_activity_json + @signed_activity_json ||= Oj.dump(serialize_payload(@quote, ActivityPub::DeleteQuoteAuthorizationSerializer, signer: @account, always_sign: true)) + end +end diff --git a/config/routes/api.rb b/config/routes/api.rb index 4040a4350fa..83190610d0b 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -18,6 +18,12 @@ namespace :api, format: false do resource :reblog, only: :create post :unreblog, to: 'reblogs#destroy' + resources :quotes, only: :index do + member do + post :revoke + end + end + resource :favourite, only: :create post :unfavourite, to: 'favourites#destroy' diff --git a/spec/requests/api/v1/statuses/quotes_spec.rb b/spec/requests/api/v1/statuses/quotes_spec.rb new file mode 100644 index 00000000000..bbf697ce323 --- /dev/null +++ b/spec/requests/api/v1/statuses/quotes_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'API V1 Statuses Quotes' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + + describe 'GET /api/v1/statuses/:status_id/quotes' do + subject do + get "/api/v1/statuses/#{status.id}/quotes", headers: headers, params: { limit: 2 } + end + + let(:scopes) { 'read:statuses' } + + let(:status) { Fabricate(:status, account: user.account) } + let!(:accepted_quote) { Fabricate(:quote, quoted_status: status, state: :accepted) } + let!(:rejected_quote) { Fabricate(:quote, quoted_status: status, state: :rejected) } + let!(:pending_quote) { Fabricate(:quote, quoted_status: status, state: :pending) } + let!(:another_accepted_quote) { Fabricate(:quote, quoted_status: status, state: :accepted) } + + context 'with an OAuth token' do + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + it_behaves_like 'forbidden for wrong scope', 'write write:statuses' + + it 'returns http success and statuses quoting this post' do + subject + + expect(response) + .to have_http_status(200) + .and include_pagination_headers( + prev: api_v1_status_quotes_url(limit: 2, since_id: another_accepted_quote.id), + next: api_v1_status_quotes_url(limit: 2, max_id: accepted_quote.id) + ) + expect(response.content_type) + .to start_with('application/json') + + expect(response.parsed_body) + .to contain_exactly( + include(id: accepted_quote.status.id.to_s), + include(id: another_accepted_quote.status.id.to_s) + ) + + expect(response.parsed_body) + .to_not include( + include(id: rejected_quote.status.id.to_s), + include(id: pending_quote.status.id.to_s) + ) + end + + context 'with a different user than the post owner' do + let(:status) { Fabricate(:status) } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + expect(response.content_type) + .to start_with('application/json') + end + end + end + + context 'without an OAuth token' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + expect(response.content_type) + .to start_with('application/json') + end + end + end + + describe 'POST /api/v1/statuses/:status_id/quotes/:id/revoke' do + subject do + post "/api/v1/statuses/#{status.id}/quotes/#{quote.status.id}/revoke", headers: headers + end + + let(:scopes) { 'write:statuses' } + + let(:status) { Fabricate(:status, account: user.account) } + let!(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted) } + + context 'with an OAuth token' do + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + it_behaves_like 'forbidden for wrong scope', 'read read:statuses' + + context 'with a different user than the post owner' do + let(:status) { Fabricate(:status) } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + expect(response.content_type) + .to start_with('application/json') + end + end + + it 'revokes the quote and returns HTTP success' do + expect { subject } + .to change { quote.reload.state }.from('accepted').to('revoked') + + expect(response) + .to have_http_status(200) + end + end + + context 'without an OAuth token' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + expect(response.content_type) + .to start_with('application/json') + end + end + end +end diff --git a/spec/serializers/activitypub/delete_quote_authorization_serializer_spec.rb b/spec/serializers/activitypub/delete_quote_authorization_serializer_spec.rb new file mode 100644 index 00000000000..a7609644f17 --- /dev/null +++ b/spec/serializers/activitypub/delete_quote_authorization_serializer_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::DeleteQuoteAuthorizationSerializer do + subject { serialized_record_json(quote, described_class, adapter: ActivityPub::Adapter) } + + describe 'serializing an object' do + let(:status) { Fabricate(:status) } + let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted, approval_uri: "https://#{Rails.configuration.x.web_domain}/approvals/1234") } + + it 'returns expected attributes' do + expect(subject.deep_symbolize_keys) + .to include( + actor: eq(ActivityPub::TagManager.instance.uri_for(status.account)), + object: quote.approval_uri, + type: 'Delete' + ) + end + end +end diff --git a/spec/services/revoke_quote_service_spec.rb b/spec/services/revoke_quote_service_spec.rb new file mode 100644 index 00000000000..282a589162c --- /dev/null +++ b/spec/services/revoke_quote_service_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe RevokeQuoteService do + subject { described_class.new } + + let!(:alice) { Fabricate(:account) } + let!(:hank) { Fabricate(:account, username: 'hank', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } + + let(:status) { Fabricate(:status, account: alice) } + + let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted, approval_uri: "https://#{Rails.configuration.x.web_domain}/approvals/1234") } + + before do + hank.follow!(alice) + end + + context 'with an accepted quote' do + it 'revokes the quote and sends a Delete activity' do + expect { described_class.new.call(quote) } + .to change { quote.reload.state }.from('accepted').to('revoked') + .and enqueue_sidekiq_job(ActivityPub::DeliveryWorker).with(/Delete/, alice.id, hank.inbox_url) + end + end +end