From 53a6cbaaa20de9d6e5d4891773e22a5201298af0 Mon Sep 17 00:00:00 2001 From: Ashwin Agarwal Date: Wed, 13 Sep 2023 19:54:39 +0000 Subject: [PATCH 1/3] Add Admin-level Status API with show, destroy, unsensitive --- .../api/v1/admin/statuses_controller.rb | 51 +++++++++++++ app/models/status.rb | 2 +- config/initializers/doorkeeper.rb | 2 + config/routes/api.rb | 6 ++ .../api/v1/admin/statuses_controller_spec.rb | 74 +++++++++++++++++++ 5 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/v1/admin/statuses_controller.rb create mode 100644 spec/controllers/api/v1/admin/statuses_controller_spec.rb diff --git a/app/controllers/api/v1/admin/statuses_controller.rb b/app/controllers/api/v1/admin/statuses_controller.rb new file mode 100644 index 00000000000000..c3d9d681275207 --- /dev/null +++ b/app/controllers/api/v1/admin/statuses_controller.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class Api::V1::Admin::StatusesController < Api::BaseController + # modeled on api/v1/admin/accounts_controller.rb + + include Authorization + include AccountableConcern + + before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:statuses' }, only: [:show] + before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:statuses' }, except: [:show] + before_action :set_status + + after_action :verify_authorized + + def show + authorize [:admin, @status], :show? + render json: @status, serializer: REST::StatusSerializer + end + + def destroy + # modeled on handle_delete from status_batch_action.rb + authorize [:admin, @status], :destroy? + ApplicationRecord.transaction do + @status.discard_with_reblogs + log_action :destroy, @status + Tombstone.find_or_create_by(uri: @status.uri, account: @status.account) + end + json = render_to_body json: @status, serializer: REST::StatusSerializer, source_requested: true + + RemovalWorker.perform_async(@status.id, { 'preserve' => @status.account.local?, 'immediate' => !@status.account.local? }) + + render json: json + end + + def unsensitive + # modeled on undo_mark_statuses_as_sensitive from approve_appeal_service.rb + authorize [:admin, @status], :update? + representative_account = Account.representative + ApplicationRecord.transaction do + UpdateStatusService.new.call(@status, representative_account.id, sensitive: false) if @status.with_media? + log_action :unsensitive, @status + end + render json: @status, serializer: REST::StatusSerializer + end + + private + + def set_status + @status = Status.find(params[:id]) + end +end diff --git a/app/models/status.rb b/app/models/status.rb index 67463b140b4c81..e177abc7eddca8 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -273,7 +273,7 @@ def non_sensitive_with_media? end def reported? - @reported ||= Report.where(target_account: account).unresolved.where('? = ANY(status_ids)', id).exists? + @reported ||= Report.where(target_account: account).where('? = ANY(status_ids)', id).exists? end def emojis diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 3eb2f7eee244b1..71c1659c483699 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -98,6 +98,7 @@ :'admin:read', :'admin:read:accounts', :'admin:read:reports', + :'admin:read:statuses', :'admin:read:domain_allows', :'admin:read:domain_blocks', :'admin:read:ip_blocks', @@ -106,6 +107,7 @@ :'admin:write', :'admin:write:accounts', :'admin:write:reports', + :'admin:write:statuses', :'admin:write:domain_allows', :'admin:write:domain_blocks', :'admin:write:ip_blocks', diff --git a/config/routes/api.rb b/config/routes/api.rb index a10e8058a580e6..0a1faac2d2f20e 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -217,6 +217,12 @@ resource :action, only: [:create], controller: 'account_actions' end + resources :statuses, only: [:show, :destroy] do + member do + post :unsensitive + end + end + resources :reports, only: [:index, :update, :show] do member do post :assign_to_self diff --git a/spec/controllers/api/v1/admin/statuses_controller_spec.rb b/spec/controllers/api/v1/admin/statuses_controller_spec.rb new file mode 100644 index 00000000000000..095fa37fc18f33 --- /dev/null +++ b/spec/controllers/api/v1/admin/statuses_controller_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V1::Admin::StatusesController do + render_views + + let(:role) { UserRole.find_by(name: 'Moderator') } + let(:user) { Fabricate(:user, role: role) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'GET #show' do + let(:scopes) { 'admin:read:statuses' } + let(:status) { Fabricate(:status) } + + before do + get :show, params: { id: status.id } + end + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' # non-admin scope + it_behaves_like 'forbidden for wrong role', '' + + it 'returns http success' do + expect(response).to have_http_status(200) + end + end + + describe 'DELETE #destroy' do + let(:scopes) { 'admin:write:statuses' } + let(:status) { Fabricate(:status) } + + before do + post :destroy, params: { id: status.id } + end + + it_behaves_like 'forbidden for wrong scope', 'admin:read:statuses' + it_behaves_like 'forbidden for wrong scope', 'write:statuses' # non-admin scope + it_behaves_like 'forbidden for wrong role', '' + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'removes the status' do + expect(Status.find_by(id: status.id)).to be_nil + end + end + + describe 'POST #unsensitive' do + let(:scopes) { 'admin:write:statuses' } + let(:media) { Fabricate(:media_attachment) } + let(:status) { Fabricate(:status, media_attachments: [media], sensitive: true) } + + before do + post :unsensitive, params: { id: status.id } + end + + it_behaves_like 'forbidden for wrong scope', 'admin:read:statuses' + it_behaves_like 'forbidden for wrong scope', 'write:statuses' # non-admin scope + it_behaves_like 'forbidden for wrong role', '' + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'unmarks status as sensitive' do + expect(status.reload.sensitive?).to be false + end + end +end From 12660539834f9bd022ea1b80c068e3ae81be9b56 Mon Sep 17 00:00:00 2001 From: Ashwin Agarwal Date: Thu, 14 Sep 2023 19:18:33 +0000 Subject: [PATCH 2/3] Add Admin-level Status Action API with delete and sensitive actions --- .../api/v1/admin/status_actions_controller.rb | 49 +++++++ app/models/admin/status_batch_action.rb | 2 +- config/routes/api.rb | 2 + .../api/v1/admin/status_actions_spec.rb | 122 ++++++++++++++++++ 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/v1/admin/status_actions_controller.rb create mode 100644 spec/requests/api/v1/admin/status_actions_spec.rb diff --git a/app/controllers/api/v1/admin/status_actions_controller.rb b/app/controllers/api/v1/admin/status_actions_controller.rb new file mode 100644 index 00000000000000..81ce190bb3a673 --- /dev/null +++ b/app/controllers/api/v1/admin/status_actions_controller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class Api::V1::Admin::StatusActionsController < Api::BaseController + # modeled on api/v1/admin/account_actions_controller.rb + + include Authorization + + # only support a subset of StatusBatchAction types + ALLOWED_TYPES = %w( + delete + sensitive + ).freeze + + before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:statuses' } + before_action :set_status + + after_action :verify_authorized + + def create + authorize [:admin, @status], :update? + raise ActiveRecord::RecordInvalid unless valid_type? + + status_batch_action = Admin::StatusBatchAction.new(resource_params) + status_batch_action.status_ids = [@status.id] + status_batch_action.current_account = current_account + status_batch_action.save! + + render_empty + end + + private + + def valid_type? + params[:type] && ALLOWED_TYPES.include?(params[:type]) + end + + def set_status + @status = Status.find(params[:status_id]) + end + + def resource_params + params.permit( + :type, + :report_id, + :text, + :send_email_notification + ) + end +end diff --git a/app/models/admin/status_batch_action.rb b/app/models/admin/status_batch_action.rb index b8bdec7223fe36..cc969f9becdf5c 100644 --- a/app/models/admin/status_batch_action.rb +++ b/app/models/admin/status_batch_action.rb @@ -31,7 +31,7 @@ def process_action! case type when 'delete' handle_delete! - when 'mark_as_sensitive' + when 'mark_as_sensitive', 'sensitive' handle_mark_as_sensitive! when 'report' handle_report! diff --git a/config/routes/api.rb b/config/routes/api.rb index 0a1faac2d2f20e..636b1626a87220 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -221,6 +221,8 @@ member do post :unsensitive end + + resource :action, only: [:create], controller: 'status_actions' end resources :reports, only: [:index, :update, :show] do diff --git a/spec/requests/api/v1/admin/status_actions_spec.rb b/spec/requests/api/v1/admin/status_actions_spec.rb new file mode 100644 index 00000000000000..c2a3fc96eecefd --- /dev/null +++ b/spec/requests/api/v1/admin/status_actions_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Status actions' do + let(:role) { UserRole.find_by(name: 'Moderator') } + let(:user) { Fabricate(:user, role: role) } + let(:scopes) { 'admin:write admin:write:statuses' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + let(:mailer) { instance_double(ActionMailer::MessageDelivery, deliver_later!: nil) } + + before do + allow(UserMailer).to receive(:warning).with(status.account.user, anything).and_return(mailer) + end + + shared_examples 'a successful notification delivery' do + it 'notifies the user about the action taken' do + subject + + expect(UserMailer).to have_received(:warning).with(status.account.user, anything).once + expect(mailer).to have_received(:deliver_later!).once + end + end + + shared_examples 'a successful logged action' do |action_type, target_type| + it 'logs action' do + subject + + log_item = Admin::ActionLog.where(action: action_type).last + + expect(log_item).to be_present + expect(log_item.account_id).to eq(user.account_id) + expect(log_item.target_id).to eq(target_type == :status ? status.id : report.id) + end + end + + describe 'POST /api/v1/admin/statuses/:id/action' do + subject do + post "/api/v1/admin/statuses/#{status.id}/action", headers: headers, params: params + end + + let(:account) { Fabricate(:account, domain: nil) } # local account for email notification + let(:media) { Fabricate(:media_attachment) } + let(:status) { Fabricate(:status, media_attachments: [media], account: account) } + + context 'with type of delete' do + let(:params) { { type: 'delete' } } + + it_behaves_like 'forbidden for wrong scope', 'admin:read:statuses' + it_behaves_like 'forbidden for wrong scope', 'write:statuses' # non-admin scope + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'a successful logged action', :destroy, :status + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'deletes the status' do + expect { subject }.to change { Status.find_by(id: status.id) }.from(status).to(nil) + end + end + + context 'with type of sensitive' do + let(:params) { { type: 'sensitive' } } + + it_behaves_like 'forbidden for wrong scope', 'admin:read:statuses' + it_behaves_like 'forbidden for wrong scope', 'write:statuses' # non-admin scope + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'a successful logged action', :update, :status + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'marks the status as sensitive' do + expect { subject }.to change { status.reload.sensitive? }.from(false).to(true) + end + end + + context 'with no type' do + let(:params) { {} } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'with invalid type' do + let(:params) { { type: 'invalid' } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'with notification delivery' do + let(:params) { { type: 'delete', send_email_notification: true } } + + it_behaves_like 'a successful notification delivery' + end + + context 'with report' do + let(:report) { Fabricate(:report) } + let(:params) { { type: 'delete', report_id: report.id } } + + it_behaves_like 'a successful logged action', :resolve, :report + + it 'resolves report' do + expect { subject }.to change { report.reload.unresolved? }.from(true).to(false) + end + end + end +end From 3eacba2562f71a10362d5cc7357a979319d16263 Mon Sep 17 00:00:00 2001 From: Ashwin Agarwal Date: Fri, 15 Sep 2023 17:06:18 +0000 Subject: [PATCH 3/3] Add by_moderator flag to Admin Status DELETE method --- app/controllers/api/v1/admin/statuses_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/api/v1/admin/statuses_controller.rb b/app/controllers/api/v1/admin/statuses_controller.rb index c3d9d681275207..9c919af4df9f83 100644 --- a/app/controllers/api/v1/admin/statuses_controller.rb +++ b/app/controllers/api/v1/admin/statuses_controller.rb @@ -23,7 +23,7 @@ def destroy ApplicationRecord.transaction do @status.discard_with_reblogs log_action :destroy, @status - Tombstone.find_or_create_by(uri: @status.uri, account: @status.account) + Tombstone.find_or_create_by(uri: @status.uri, account: @status.account, by_moderator: true) end json = render_to_body json: @status, serializer: REST::StatusSerializer, source_requested: true