From 0396acf39ea902688374fac65fa7ef5dc4c05512 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 25 Aug 2022 20:39:40 +0200 Subject: Add audit log entries for user roles (#19040) * Refactor audit log schema * Add audit log entries for user roles --- app/controllers/admin/roles_controller.rb | 3 +++ app/controllers/admin/users/roles_controller.rb | 1 + app/controllers/concerns/accountable_concern.rb | 8 ++++++-- 3 files changed, 10 insertions(+), 2 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/admin/roles_controller.rb b/app/controllers/admin/roles_controller.rb index 3e502ccc4..d76aa745b 100644 --- a/app/controllers/admin/roles_controller.rb +++ b/app/controllers/admin/roles_controller.rb @@ -23,6 +23,7 @@ module Admin @role.current_account = current_account if @role.save + log_action :create, @role redirect_to admin_roles_path else render :new @@ -39,6 +40,7 @@ module Admin @role.current_account = current_account if @role.update(resource_params) + log_action :update, @role redirect_to admin_roles_path else render :edit @@ -48,6 +50,7 @@ module Admin def destroy authorize @role, :destroy? @role.destroy! + log_action :destroy, @role redirect_to admin_roles_path end diff --git a/app/controllers/admin/users/roles_controller.rb b/app/controllers/admin/users/roles_controller.rb index 0db50cee9..f5dfc643d 100644 --- a/app/controllers/admin/users/roles_controller.rb +++ b/app/controllers/admin/users/roles_controller.rb @@ -14,6 +14,7 @@ module Admin @user.current_account = current_account if @user.update(resource_params) + log_action :change_role, @user redirect_to admin_account_path(@user.account_id), notice: I18n.t('admin.accounts.change_role.changed_msg') else render :show diff --git a/app/controllers/concerns/accountable_concern.rb b/app/controllers/concerns/accountable_concern.rb index 87d62478d..c1349915f 100644 --- a/app/controllers/concerns/accountable_concern.rb +++ b/app/controllers/concerns/accountable_concern.rb @@ -3,7 +3,11 @@ module AccountableConcern extend ActiveSupport::Concern - def log_action(action, target, options = {}) - Admin::ActionLog.create(account: current_account, action: action, target: target, recorded_changes: options.stringify_keys) + def log_action(action, target) + Admin::ActionLog.create( + account: current_account, + action: action, + target: target + ) end end -- cgit From 5b0e8cc92b9ca0ab0dc24366d95f67a88c470173 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 25 Aug 2022 23:33:34 +0200 Subject: Add ability to select all accounts matching search for batch actions (#19053) --- app/controllers/admin/accounts_controller.rb | 6 +++- app/javascript/packs/admin.js | 53 ++++++++++++++++++++++++++++ app/javascript/styles/mastodon/tables.scss | 49 +++++++++++++++++++++++++ app/models/custom_filter_status.rb | 2 +- app/models/form/account_batch.rb | 13 +++++-- app/views/admin/accounts/index.html.haml | 9 +++++ config/locales/en.yml | 10 ++++++ 7 files changed, 138 insertions(+), 4 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 46c9aba91..40bf685c5 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -16,7 +16,11 @@ module Admin def batch authorize :account, :index? - @form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button)) + @form = Form::AccountBatch.new(form_account_batch_params) + @form.current_account = current_account + @form.action = action_from_button + @form.select_all_matching = params[:select_all_matching] + @form.query = filtered_accounts @form.save rescue ActionController::ParameterMissing flash[:alert] = I18n.t('admin.accounts.no_account_selected') diff --git a/app/javascript/packs/admin.js b/app/javascript/packs/admin.js index a3ed1ffed..b733d6b18 100644 --- a/app/javascript/packs/admin.js +++ b/app/javascript/packs/admin.js @@ -4,18 +4,71 @@ import ready from '../mastodon/ready'; const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]'; +const showSelectAll = () => { + const selectAllMatchingElement = document.querySelector('.batch-table__select-all'); + selectAllMatchingElement.classList.add('active'); +}; + +const hideSelectAll = () => { + const selectAllMatchingElement = document.querySelector('.batch-table__select-all'); + const hiddenField = document.querySelector('#select_all_matching'); + const selectedMsg = document.querySelector('.batch-table__select-all .selected'); + const notSelectedMsg = document.querySelector('.batch-table__select-all .not-selected'); + + selectAllMatchingElement.classList.remove('active'); + selectedMsg.classList.remove('active'); + notSelectedMsg.classList.add('active'); + hiddenField.value = '0'; +}; + delegate(document, '#batch_checkbox_all', 'change', ({ target }) => { + const selectAllMatchingElement = document.querySelector('.batch-table__select-all'); + [].forEach.call(document.querySelectorAll(batchCheckboxClassName), (content) => { content.checked = target.checked; }); + + if (selectAllMatchingElement) { + if (target.checked) { + showSelectAll(); + } else { + hideSelectAll(); + } + } +}); + +delegate(document, '.batch-table__select-all button', 'click', () => { + const hiddenField = document.querySelector('#select_all_matching'); + const active = hiddenField.value === '1'; + const selectedMsg = document.querySelector('.batch-table__select-all .selected'); + const notSelectedMsg = document.querySelector('.batch-table__select-all .not-selected'); + + if (active) { + hiddenField.value = '0'; + selectedMsg.classList.remove('active'); + notSelectedMsg.classList.add('active'); + } else { + hiddenField.value = '1'; + notSelectedMsg.classList.remove('active'); + selectedMsg.classList.add('active'); + } }); delegate(document, batchCheckboxClassName, 'change', () => { const checkAllElement = document.querySelector('#batch_checkbox_all'); + const selectAllMatchingElement = document.querySelector('.batch-table__select-all'); if (checkAllElement) { checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked); checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked); + + if (selectAllMatchingElement) { + if (checkAllElement.checked) { + showSelectAll(); + } else { + hideSelectAll(); + } + } } }); diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss index 431b8a73a..39211910f 100644 --- a/app/javascript/styles/mastodon/tables.scss +++ b/app/javascript/styles/mastodon/tables.scss @@ -190,6 +190,55 @@ a.table-action-link { } } + &__select-all { + background: $ui-base-color; + height: 47px; + align-items: center; + justify-content: center; + border: 1px solid darken($ui-base-color, 8%); + border-top: 0; + color: $secondary-text-color; + display: none; + + &.active { + display: flex; + } + + .selected, + .not-selected { + display: none; + + &.active { + display: block; + } + } + + strong { + font-weight: 700; + } + + span { + padding: 8px; + display: inline-block; + } + + button { + background: transparent; + border: 0; + font: inherit; + color: $highlight-text-color; + border-radius: 4px; + font-weight: 700; + padding: 8px; + + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 8%); + } + } + } + &__form { padding: 16px; border: 1px solid darken($ui-base-color, 8%); diff --git a/app/models/custom_filter_status.rb b/app/models/custom_filter_status.rb index b6bea1394..e748d6963 100644 --- a/app/models/custom_filter_status.rb +++ b/app/models/custom_filter_status.rb @@ -5,7 +5,7 @@ # # id :bigint(8) not null, primary key # custom_filter_id :bigint(8) not null -# status_id :bigint(8) default(""), not null +# status_id :bigint(8) not null # created_at :datetime not null # updated_at :datetime not null # diff --git a/app/models/form/account_batch.rb b/app/models/form/account_batch.rb index 98f2cad3e..5cfcf7205 100644 --- a/app/models/form/account_batch.rb +++ b/app/models/form/account_batch.rb @@ -6,7 +6,8 @@ class Form::AccountBatch include AccountableConcern include Payloadable - attr_accessor :account_ids, :action, :current_account + attr_accessor :account_ids, :action, :current_account, + :select_all_matching, :query def save case action @@ -60,7 +61,11 @@ class Form::AccountBatch end def accounts - Account.where(id: account_ids) + if select_all_matching? + query + else + Account.where(id: account_ids) + end end def approve! @@ -118,4 +123,8 @@ class Form::AccountBatch log_action(:approve, account.user) account.user.approve! end + + def select_all_matching? + select_all_matching == '1' + end end diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml index cb378f0ed..670a09a2d 100644 --- a/app/views/admin/accounts/index.html.haml +++ b/app/views/admin/accounts/index.html.haml @@ -37,6 +37,7 @@ = form_for(@form, url: batch_admin_accounts_path) do |f| = hidden_field_tag :page, params[:page] || 1 + = hidden_field_tag :select_all_matching, '0' - AccountFilter::KEYS.each do |key| = hidden_field_tag key, params[key] if params[key].present? @@ -52,6 +53,14 @@ = f.button safe_join([fa_icon('times'), t('admin.accounts.reject')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } = f.button safe_join([fa_icon('lock'), t('admin.accounts.perform_full_suspension')]), name: :suspend, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + - if true || @accounts.total_count > @accounts.size + .batch-table__select-all + .not-selected.active + %span= t('generic.all_items_on_page_selected_html', count: @accounts.size) + %button{ type: 'button' }= t('generic.select_all_matching_items', count: @accounts.total_count) + .selected + %span= t('generic.all_matching_items_selected_html', count: @accounts.total_count) + %button{ type: 'button' }= t('generic.deselect') .batch-table__body - if @accounts.empty? = nothing_here 'nothing-here--under-tabs' diff --git a/config/locales/en.yml b/config/locales/en.yml index 5c309ab11..6aa87e4a0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1227,12 +1227,22 @@ en: trending_now: Trending now generic: all: All + all_items_on_page_selected_html: + one: "%{count} item on this page is selected." + other: All %{count} items on this page are selected. + all_matching_items_selected_html: + one: "%{count} item matching your search is selected." + other: All %{count} items matching your search are selected. changes_saved_msg: Changes successfully saved! copy: Copy delete: Delete + deselect: Deselect all none: None order_by: Order by save_changes: Save changes + select_all_matching_items: + one: Select %{count} item matching your search. + other: Select all %{count} items matching your search. today: today validation_errors: one: Something isn't quite right yet! Please review the error below -- cgit From 0b3e4fd5de392969b624719b2eb3f86277b6ac1f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 25 Aug 2022 23:38:22 +0200 Subject: Remove digest e-mails (#17985) * Remove digest e-mails * Remove digest-related code --- app/controllers/settings/preferences_controller.rb | 2 +- app/mailers/notification_mailer.rb | 18 --------- app/models/user.rb | 4 -- app/views/notification_mailer/digest.html.haml | 44 ---------------------- app/views/notification_mailer/digest.text.erb | 15 -------- .../preferences/notifications/show.html.haml | 4 -- app/workers/digest_mailer_worker.rb | 21 ----------- app/workers/scheduler/email_scheduler.rb | 25 ------------ config/locales/en.yml | 11 ------ config/sidekiq.yml | 4 -- spec/mailers/notification_mailer_spec.rb | 31 --------------- spec/workers/digest_mailer_worker_spec.rb | 36 ------------------ 12 files changed, 1 insertion(+), 214 deletions(-) delete mode 100644 app/views/notification_mailer/digest.html.haml delete mode 100644 app/views/notification_mailer/digest.text.erb delete mode 100644 app/workers/digest_mailer_worker.rb delete mode 100644 app/workers/scheduler/email_scheduler.rb delete mode 100644 spec/workers/digest_mailer_worker_spec.rb (limited to 'app/controllers') diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index bfe651bc6..f5d5c1244 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -55,7 +55,7 @@ class Settings::PreferencesController < Settings::BaseController :setting_trends, :setting_crop_images, :setting_always_send_emails, - notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account trending_tag appeal), + notification_emails: %i(follow follow_request reblog favourite mention report pending_account trending_tag appeal), interactions: %i(must_be_follower must_be_following must_be_following_dm) ) end diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 9e683b6a1..ab73826ab 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -66,24 +66,6 @@ class NotificationMailer < ApplicationMailer end end - def digest(recipient, **opts) - return unless recipient.user.functional? - - @me = recipient - @since = opts[:since] || [@me.user.last_emailed_at, (@me.user.current_sign_in_at + 1.day)].compact.max - @notifications_count = Notification.where(account: @me, activity_type: 'Mention').where('created_at > ?', @since).count - - return if @notifications_count.zero? - - @notifications = Notification.where(account: @me, activity_type: 'Mention').where('created_at > ?', @since).limit(40) - @follows_since = Notification.where(account: @me, activity_type: 'Follow').where('created_at > ?', @since).count - - locale_for_account(@me) do - mail to: @me.user.email, - subject: I18n.t(:subject, scope: [:notification_mailer, :digest], count: @notifications_count) - end - end - private def thread_by_conversation(conversation) diff --git a/app/models/user.rb b/app/models/user.rb index 18b9d5928..342f5e6cc 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -289,10 +289,6 @@ class User < ApplicationRecord settings.default_privacy || (account.locked? ? 'private' : 'public') end - def allows_digest_emails? - settings.notification_emails['digest'] - end - def allows_report_emails? settings.notification_emails['report'] end diff --git a/app/views/notification_mailer/digest.html.haml b/app/views/notification_mailer/digest.html.haml deleted file mode 100644 index a94ace228..000000000 --- a/app/views/notification_mailer/digest.html.haml +++ /dev/null @@ -1,44 +0,0 @@ -%table.email-table{ cellspacing: 0, cellpadding: 0 } - %tbody - %tr - %td.email-body - .email-container - %table.content-section{ cellspacing: 0, cellpadding: 0 } - %tbody - %tr - %td.content-cell.darker.hero-with-button - .email-row - .col-6 - %table.column{ cellspacing: 0, cellpadding: 0 } - %tbody - %tr - %td.column-cell.text-center.padded - %h1= t 'notification_mailer.digest.title' - %p.lead= t('notification_mailer.digest.body', since: l((@me.user_current_sign_in_at || @since).to_date, format: :short), instance: site_hostname) - %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 } - %tbody - %tr - %td.button-primary - = link_to web_url do - %span= t 'notification_mailer.digest.action' - -- @notifications.each_with_index do |n, i| - = render 'status', status: n.target_status, i: i - -- unless @follows_since.zero? - %table.email-table{ cellspacing: 0, cellpadding: 0 } - %tbody - %tr - %td.email-body - .email-container - %table.content-section{ cellspacing: 0, cellpadding: 0 } - %tbody - %tr - %td.content-cell.content-start.border-top - .email-row - .col-6 - %table.column{ cellspacing: 0, cellpadding: 0 } - %tbody - %tr - %td.column-cell.text-center - %p= t('notification_mailer.digest.new_followers_summary', count: @follows_since) diff --git a/app/views/notification_mailer/digest.text.erb b/app/views/notification_mailer/digest.text.erb deleted file mode 100644 index 0f84a4ef0..000000000 --- a/app/views/notification_mailer/digest.text.erb +++ /dev/null @@ -1,15 +0,0 @@ -<%= raw t('application_mailer.salutation', name: display_name(@me)) %> - -<%= raw t('notification_mailer.digest.body', since: l(@me.user_current_sign_in_at || @since), instance: root_url) %> -<% @notifications.each do |notification| %> - -* <%= raw t('notification_mailer.digest.mention', name: notification.from_account.pretty_acct) %> - - <%= raw extract_status_plain_text(notification.target_status) %> - - <%= raw t('application_mailer.view')%> <%= web_url("statuses/#{notification.target_status.id}") %> -<% end %> -<% if @follows_since > 0 %> - -<%= raw t('notification_mailer.digest.new_followers_summary', count: @follows_since) %> -<% end %> diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml index bc7afb993..f00dbadd4 100644 --- a/app/views/settings/preferences/notifications/show.html.haml +++ b/app/views/settings/preferences/notifications/show.html.haml @@ -26,10 +26,6 @@ .fields-group = f.input :setting_always_send_emails, as: :boolean, wrapper: :with_label - .fields-group - = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| - = ff.input :digest, as: :boolean, wrapper: :with_label - %h4= t 'notifications.other_settings' .fields-group diff --git a/app/workers/digest_mailer_worker.rb b/app/workers/digest_mailer_worker.rb deleted file mode 100644 index 21f1c357a..000000000 --- a/app/workers/digest_mailer_worker.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -class DigestMailerWorker - include Sidekiq::Worker - - sidekiq_options queue: 'mailers' - - attr_reader :user - - def perform(user_id) - @user = User.find(user_id) - deliver_digest if @user.allows_digest_emails? - end - - private - - def deliver_digest - NotificationMailer.digest(user.account).deliver_now! - user.touch(:last_emailed_at) - end -end diff --git a/app/workers/scheduler/email_scheduler.rb b/app/workers/scheduler/email_scheduler.rb deleted file mode 100644 index c052f2fce..000000000 --- a/app/workers/scheduler/email_scheduler.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -class Scheduler::EmailScheduler - include Sidekiq::Worker - - sidekiq_options retry: 0 - - FREQUENCY = 7.days.freeze - SIGN_IN_OFFSET = 1.day.freeze - - def perform - eligible_users.reorder(nil).find_each do |user| - next unless user.allows_digest_emails? - DigestMailerWorker.perform_async(user.id) - end - end - - private - - def eligible_users - User.emailable - .where('current_sign_in_at < ?', (FREQUENCY + SIGN_IN_OFFSET).ago) - .where('last_emailed_at IS NULL OR last_emailed_at < ?', FREQUENCY.ago) - end -end diff --git a/config/locales/en.yml b/config/locales/en.yml index 6aa87e4a0..72ebfafba 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1351,17 +1351,6 @@ en: subject: "%{name} submitted a report" sign_up: subject: "%{name} signed up" - digest: - action: View all notifications - body: Here is a brief summary of the messages you missed since your last visit on %{since} - mention: "%{name} mentioned you in:" - new_followers_summary: - one: Also, you have acquired one new follower while being away! Yay! - other: Also, you have acquired %{count} new followers while being away! Amazing! - subject: - one: "1 new notification since your last visit 🐘" - other: "%{count} new notifications since your last visit 🐘" - title: In your absence... favourite: body: 'Your post was favourited by %{name}:' subject: "%{name} favourited your post" diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 2a3871468..9ec6eb5ec 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -49,10 +49,6 @@ cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *' class: Scheduler::IpCleanupScheduler queue: scheduler - email_scheduler: - cron: '0 10 * * 2' - class: Scheduler::EmailScheduler - queue: scheduler backup_cleanup_scheduler: cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *' class: Scheduler::BackupCleanupScheduler diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb index 2ca4e26fa..29bdc349b 100644 --- a/spec/mailers/notification_mailer_spec.rb +++ b/spec/mailers/notification_mailer_spec.rb @@ -101,35 +101,4 @@ RSpec.describe NotificationMailer, type: :mailer do expect(mail.body.encoded).to match("bob has requested to follow you") end end - - describe 'digest' do - before do - mention = Fabricate(:mention, account: receiver.account, status: foreign_status) - Fabricate(:notification, account: receiver.account, activity: mention) - sender.follow!(receiver.account) - end - - context do - let!(:mail) { NotificationMailer.digest(receiver.account, since: 5.days.ago) } - - include_examples 'localized subject', 'notification_mailer.digest.subject', count: 1, name: 'bob' - - it 'renders the headers' do - expect(mail.subject).to match('notification since your last') - expect(mail.to).to eq([receiver.email]) - end - - it 'renders the body' do - expect(mail.body.encoded).to match('brief summary') - expect(mail.body.encoded).to include 'The body of the foreign status' - expect(mail.body.encoded).to include sender.username - end - end - - it 'includes activities since the receiver last signed in' do - receiver.update!(last_emailed_at: nil, current_sign_in_at: '2000-03-01T00:00:00Z') - mail = NotificationMailer.digest(receiver.account) - expect(mail.body.encoded).to include 'Mar 01, 2000, 00:00' - end - end end diff --git a/spec/workers/digest_mailer_worker_spec.rb b/spec/workers/digest_mailer_worker_spec.rb deleted file mode 100644 index db3b1390d..000000000 --- a/spec/workers/digest_mailer_worker_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe DigestMailerWorker do - describe 'perform' do - let(:user) { Fabricate(:user, last_emailed_at: 3.days.ago) } - - context 'for a user who receives digests' do - it 'sends the email' do - service = double(deliver_now!: nil) - allow(NotificationMailer).to receive(:digest).and_return(service) - update_user_digest_setting(true) - described_class.perform_async(user.id) - - expect(NotificationMailer).to have_received(:digest) - expect(user.reload.last_emailed_at).to be_within(1).of(Time.now.utc) - end - end - - context 'for a user who does not receive digests' do - it 'does not send the email' do - allow(NotificationMailer).to receive(:digest) - update_user_digest_setting(false) - described_class.perform_async(user.id) - - expect(NotificationMailer).not_to have_received(:digest) - expect(user.last_emailed_at).to be_within(1).of(3.days.ago) - end - end - - def update_user_digest_setting(value) - user.settings['notification_emails'] = user.settings['notification_emails'].merge('digest' => value) - end - end -end -- cgit From b399d79545e5e5430cb9d6a2c936efc244b69a75 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 27 Aug 2022 20:56:47 +0200 Subject: Add admin API for managing IP blocks (#19065) --- .../api/v1/admin/ip_blocks_controller.rb | 99 ++++++++++++++++++++++ app/models/ip_block.rb | 3 +- app/policies/ip_block_policy.rb | 4 + app/serializers/rest/admin/ip_block_serializer.rb | 14 +++ config/routes.rb | 1 + 5 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/v1/admin/ip_blocks_controller.rb create mode 100644 app/serializers/rest/admin/ip_block_serializer.rb (limited to 'app/controllers') diff --git a/app/controllers/api/v1/admin/ip_blocks_controller.rb b/app/controllers/api/v1/admin/ip_blocks_controller.rb new file mode 100644 index 000000000..f13d63267 --- /dev/null +++ b/app/controllers/api/v1/admin/ip_blocks_controller.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +class Api::V1::Admin::IpBlocksController < Api::BaseController + include Authorization + include AccountableConcern + + LIMIT = 100 + + before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:ip_blocks' }, only: [:index, :show] + before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:ip_blocks' }, except: [:index, :show] + before_action :set_ip_blocks, only: :index + before_action :set_ip_block, only: [:show, :update, :destroy] + + after_action :verify_authorized + after_action :insert_pagination_headers, only: :index + + PAGINATION_PARAMS = %i( + limit + ).freeze + + def create + authorize :ip_block, :create? + + @ip_block = IpBlock.create!(resource_params) + log_action :create, @ip_block + + render json: @ip_block, serializer: REST::Admin::IpBlockSerializer + end + + def index + authorize :ip_block, :index? + render json: @ip_blocks, each_serializer: REST::Admin::IpBlockSerializer + end + + def show + authorize @ip_block, :show? + render json: @ip_block, serializer: REST::Admin::IpBlockSerializer + end + + def update + authorize @ip_block, :update? + + @ip_block.update(resource_params) + log_action :update, @ip_block + + render json: @ip_block, serializer: REST::Admin::IpBlockSerializer + end + + def destroy + authorize @ip_block, :destroy? + + @ip_block.destroy! + log_action :destroy, @ip_block + + render json: @ip_block, serializer: REST::Admin::IpBlockSerializer + end + + private + + def set_ip_blocks + @ip_blocks = IpBlock.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + end + + def set_ip_block + @ip_block = IpBlock.find(params[:id]) + end + + def resource_params + params.permit(:ip, :severity, :comment, :expires_in) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_admin_ip_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue? + end + + def prev_path + api_v1_admin_ip_blocks_url(pagination_params(min_id: pagination_since_id)) unless @ip_blocks.empty? + end + + def pagination_max_id + @ip_blocks.last.id + end + + def pagination_since_id + @ip_blocks.first.id + end + + def records_continue? + @ip_blocks.size == limit_param(LIMIT) + end + + def pagination_params(core_params) + params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params) + end +end diff --git a/app/models/ip_block.rb b/app/models/ip_block.rb index f40c8a0b1..8666f4248 100644 --- a/app/models/ip_block.rb +++ b/app/models/ip_block.rb @@ -16,6 +16,7 @@ class IpBlock < ApplicationRecord CACHE_KEY = 'blocked_ips' include Expireable + include Paginable enum severity: { sign_up_requires_approval: 5000, @@ -28,7 +29,7 @@ class IpBlock < ApplicationRecord after_commit :reset_cache def to_log_human_identifier - "#{record.ip}/#{record.ip.prefix}" + "#{ip}/#{ip.prefix}" end class << self diff --git a/app/policies/ip_block_policy.rb b/app/policies/ip_block_policy.rb index 1abc97ad8..2986a4fdb 100644 --- a/app/policies/ip_block_policy.rb +++ b/app/policies/ip_block_policy.rb @@ -9,6 +9,10 @@ class IpBlockPolicy < ApplicationPolicy role.can?(:manage_blocks) end + def update? + role.can?(:manage_blocks) + end + def destroy? role.can?(:manage_blocks) end diff --git a/app/serializers/rest/admin/ip_block_serializer.rb b/app/serializers/rest/admin/ip_block_serializer.rb new file mode 100644 index 000000000..6a38f8b56 --- /dev/null +++ b/app/serializers/rest/admin/ip_block_serializer.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class REST::Admin::IpBlockSerializer < ActiveModel::Serializer + attributes :id, :ip, :severity, :comment, + :created_at, :expires_at + + def id + object.id.to_s + end + + def ip + "#{object.ip}/#{object.ip.prefix}" + end +end diff --git a/config/routes.rb b/config/routes.rb index dff0add3a..1168c9aee 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -591,6 +591,7 @@ Rails.application.routes.draw do resources :domain_allows, only: [:index, :show, :create, :destroy] resources :domain_blocks, only: [:index, :show, :update, :create, :destroy] + resources :ip_blocks, only: [:index, :show, :update, :create, :destroy] namespace :trends do resources :tags, only: [:index] -- cgit From c556c3a0d1e54a6b07bbdd8f76cbb43672a91fd1 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 28 Aug 2022 03:31:54 +0200 Subject: Add admin API for managing canonical e-mail blocks (#19067) --- .../v1/admin/canonical_email_blocks_controller.rb | 99 ++++++++++++++++++++++ app/helpers/admin/action_logs_helper.rb | 8 +- app/models/admin/action_log_filter.rb | 5 ++ app/models/canonical_email_block.rb | 17 ++-- app/policies/canonical_email_block_policy.rb | 23 +++++ .../rest/admin/canonical_email_block_serializer.rb | 9 ++ config/locales/en.yml | 6 ++ config/routes.rb | 6 ++ ...95229_change_canonical_email_blocks_nullable.rb | 5 ++ db/schema.rb | 4 +- lib/mastodon/canonical_email_blocks_cli.rb | 31 ++----- 11 files changed, 177 insertions(+), 36 deletions(-) create mode 100644 app/controllers/api/v1/admin/canonical_email_blocks_controller.rb create mode 100644 app/policies/canonical_email_block_policy.rb create mode 100644 app/serializers/rest/admin/canonical_email_block_serializer.rb create mode 100644 db/migrate/20220827195229_change_canonical_email_blocks_nullable.rb (limited to 'app/controllers') diff --git a/app/controllers/api/v1/admin/canonical_email_blocks_controller.rb b/app/controllers/api/v1/admin/canonical_email_blocks_controller.rb new file mode 100644 index 000000000..bf8a6a131 --- /dev/null +++ b/app/controllers/api/v1/admin/canonical_email_blocks_controller.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +class Api::V1::Admin::CanonicalEmailBlocksController < Api::BaseController + include Authorization + include AccountableConcern + + LIMIT = 100 + + before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:canonical_email_blocks' }, only: [:index, :show, :test] + before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:canonical_email_blocks' }, except: [:index, :show, :test] + + before_action :set_canonical_email_blocks, only: :index + before_action :set_canonical_email_blocks_from_test, only: [:test] + before_action :set_canonical_email_block, only: [:show, :destroy] + + after_action :verify_authorized + after_action :insert_pagination_headers, only: :index + + PAGINATION_PARAMS = %i(limit).freeze + + def index + authorize :canonical_email_block, :index? + render json: @canonical_email_blocks, each_serializer: REST::Admin::CanonicalEmailBlockSerializer + end + + def show + authorize @canonical_email_block, :show? + render json: @canonical_email_block, serializer: REST::Admin::CanonicalEmailBlockSerializer + end + + def test + authorize :canonical_email_block, :test? + render json: @canonical_email_blocks, each_serializer: REST::Admin::CanonicalEmailBlockSerializer + end + + def create + authorize :canonical_email_block, :create? + + @canonical_email_block = CanonicalEmailBlock.create!(resource_params) + log_action :create, @canonical_email_block + + render json: @canonical_email_block, serializer: REST::Admin::CanonicalEmailBlockSerializer + end + + def destroy + authorize @canonical_email_block, :destroy? + + @canonical_email_block.destroy! + log_action :destroy, @canonical_email_block + + render json: @canonical_email_block, serializer: REST::Admin::CanonicalEmailBlockSerializer + end + + private + + def resource_params + params.permit(:canonical_email_hash, :email) + end + + def set_canonical_email_blocks + @canonical_email_blocks = CanonicalEmailBlock.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + end + + def set_canonical_email_blocks_from_test + @canonical_email_blocks = CanonicalEmailBlock.matching_email(params[:email]) + end + + def set_canonical_email_block + @canonical_email_block = CanonicalEmailBlock.find(params[:id]) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_admin_canonical_email_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue? + end + + def prev_path + api_v1_admin_canonical_email_blocks_url(pagination_params(min_id: pagination_since_id)) unless @canonical_email_blocks.empty? + end + + def pagination_max_id + @canonical_email_blocks.last.id + end + + def pagination_since_id + @canonical_email_blocks.first.id + end + + def records_continue? + @canonical_email_blocks.size == limit_param(LIMIT) + end + + def pagination_params(core_params) + params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params) + end +end diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 3e9fe17f4..fd1977ac5 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -9,8 +9,6 @@ module Admin::ActionLogsHelper link_to log.human_identifier, admin_account_path(log.route_param) when 'UserRole' link_to log.human_identifier, admin_roles_path(log.target_id) - when 'CustomEmoji' - log.human_identifier when 'Report' link_to "##{log.human_identifier}", admin_report_path(log.target_id) when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock', 'UnavailableDomain' @@ -21,10 +19,10 @@ module Admin::ActionLogsHelper link_to log.human_identifier, admin_account_path(log.target_id) when 'Announcement' link_to truncate(log.human_identifier), edit_admin_announcement_path(log.target_id) - when 'IpBlock' - log.human_identifier - when 'Instance' + when 'IpBlock', 'Instance', 'CustomEmoji' log.human_identifier + when 'CanonicalEmailBlock' + content_tag(:samp, log.human_identifier[0...7], title: log.human_identifier) when 'Appeal' link_to log.human_identifier, disputes_strike_path(log.route_param) end diff --git a/app/models/admin/action_log_filter.rb b/app/models/admin/action_log_filter.rb index 6382cd782..c7a7e1a4c 100644 --- a/app/models/admin/action_log_filter.rb +++ b/app/models/admin/action_log_filter.rb @@ -22,18 +22,22 @@ class Admin::ActionLogFilter create_domain_allow: { target_type: 'DomainAllow', action: 'create' }.freeze, create_domain_block: { target_type: 'DomainBlock', action: 'create' }.freeze, create_email_domain_block: { target_type: 'EmailDomainBlock', action: 'create' }.freeze, + create_ip_block: { target_type: 'IpBlock', action: 'create' }.freeze, create_unavailable_domain: { target_type: 'UnavailableDomain', action: 'create' }.freeze, create_user_role: { target_type: 'UserRole', action: 'create' }.freeze, + create_canonical_email_block: { target_type: 'CanonicalEmailBlock', action: 'create' }.freeze, demote_user: { target_type: 'User', action: 'demote' }.freeze, destroy_announcement: { target_type: 'Announcement', action: 'destroy' }.freeze, destroy_custom_emoji: { target_type: 'CustomEmoji', action: 'destroy' }.freeze, destroy_domain_allow: { target_type: 'DomainAllow', action: 'destroy' }.freeze, destroy_domain_block: { target_type: 'DomainBlock', action: 'destroy' }.freeze, + destroy_ip_block: { target_type: 'IpBlock', action: 'destroy' }.freeze, destroy_email_domain_block: { target_type: 'EmailDomainBlock', action: 'destroy' }.freeze, destroy_instance: { target_type: 'Instance', action: 'destroy' }.freeze, destroy_unavailable_domain: { target_type: 'UnavailableDomain', action: 'destroy' }.freeze, destroy_status: { target_type: 'Status', action: 'destroy' }.freeze, destroy_user_role: { target_type: 'UserRole', action: 'destroy' }.freeze, + destroy_canonical_email_block: { target_type: 'CanonicalEmailBlock', action: 'destroy' }.freeze, disable_2fa_user: { target_type: 'User', action: 'disable' }.freeze, disable_custom_emoji: { target_type: 'CustomEmoji', action: 'disable' }.freeze, disable_user: { target_type: 'User', action: 'disable' }.freeze, @@ -56,6 +60,7 @@ class Admin::ActionLogFilter update_custom_emoji: { target_type: 'CustomEmoji', action: 'update' }.freeze, update_status: { target_type: 'Status', action: 'update' }.freeze, update_user_role: { target_type: 'UserRole', action: 'update' }.freeze, + update_ip_block: { target_type: 'IpBlock', action: 'update' }.freeze, unblock_email_account: { target_type: 'Account', action: 'unblock_email' }.freeze, }.freeze diff --git a/app/models/canonical_email_block.rb b/app/models/canonical_email_block.rb index 94781386c..1eb69ac67 100644 --- a/app/models/canonical_email_block.rb +++ b/app/models/canonical_email_block.rb @@ -5,27 +5,30 @@ # # id :bigint(8) not null, primary key # canonical_email_hash :string default(""), not null -# reference_account_id :bigint(8) not null +# reference_account_id :bigint(8) # created_at :datetime not null # updated_at :datetime not null # class CanonicalEmailBlock < ApplicationRecord include EmailHelper + include Paginable - belongs_to :reference_account, class_name: 'Account' + belongs_to :reference_account, class_name: 'Account', optional: true validates :canonical_email_hash, presence: true, uniqueness: true + scope :matching_email, ->(email) { where(canonical_email_hash: email_to_canonical_email_hash(email)) } + + def to_log_human_identifier + canonical_email_hash + end + def email=(email) self.canonical_email_hash = email_to_canonical_email_hash(email) end def self.block?(email) - where(canonical_email_hash: email_to_canonical_email_hash(email)).exists? - end - - def self.find_blocks(email) - where(canonical_email_hash: email_to_canonical_email_hash(email)) + matching_email(email).exists? end end diff --git a/app/policies/canonical_email_block_policy.rb b/app/policies/canonical_email_block_policy.rb new file mode 100644 index 000000000..8d76075c9 --- /dev/null +++ b/app/policies/canonical_email_block_policy.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class CanonicalEmailBlockPolicy < ApplicationPolicy + def index? + role.can?(:manage_blocks) + end + + def show? + role.can?(:manage_blocks) + end + + def test? + role.can?(:manage_blocks) + end + + def create? + role.can?(:manage_blocks) + end + + def destroy? + role.can?(:manage_blocks) + end +end diff --git a/app/serializers/rest/admin/canonical_email_block_serializer.rb b/app/serializers/rest/admin/canonical_email_block_serializer.rb new file mode 100644 index 000000000..fe385940a --- /dev/null +++ b/app/serializers/rest/admin/canonical_email_block_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class REST::Admin::CanonicalEmailBlockSerializer < ActiveModel::Serializer + attributes :id, :canonical_email_hash + + def id + object.id.to_s + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 72ebfafba..0b721c163 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -239,6 +239,7 @@ en: confirm_user: Confirm User create_account_warning: Create Warning create_announcement: Create Announcement + create_canonical_email_block: Create E-mail Block create_custom_emoji: Create Custom Emoji create_domain_allow: Create Domain Allow create_domain_block: Create Domain Block @@ -248,6 +249,7 @@ en: create_user_role: Create Role demote_user: Demote User destroy_announcement: Delete Announcement + destroy_canonical_email_block: Delete E-mail Block destroy_custom_emoji: Delete Custom Emoji destroy_domain_allow: Delete Domain Allow destroy_domain_block: Delete Domain Block @@ -283,6 +285,7 @@ en: update_announcement: Update Announcement update_custom_emoji: Update Custom Emoji update_domain_block: Update Domain Block + update_ip_block: Update IP rule update_status: Update Post update_user_role: Update Role actions: @@ -294,6 +297,7 @@ en: confirm_user_html: "%{name} confirmed e-mail address of user %{target}" create_account_warning_html: "%{name} sent a warning to %{target}" create_announcement_html: "%{name} created new announcement %{target}" + create_canonical_email_block_html: "%{name} blocked e-mail with the hash %{target}" create_custom_emoji_html: "%{name} uploaded new emoji %{target}" create_domain_allow_html: "%{name} allowed federation with domain %{target}" create_domain_block_html: "%{name} blocked domain %{target}" @@ -303,6 +307,7 @@ en: create_user_role_html: "%{name} created %{target} role" demote_user_html: "%{name} demoted user %{target}" destroy_announcement_html: "%{name} deleted announcement %{target}" + destroy_canonical_email_block_html: "%{name} unblocked e-mail with the hash %{target}" destroy_custom_emoji_html: "%{name} deleted emoji %{target}" destroy_domain_allow_html: "%{name} disallowed federation with domain %{target}" destroy_domain_block_html: "%{name} unblocked domain %{target}" @@ -338,6 +343,7 @@ en: update_announcement_html: "%{name} updated announcement %{target}" update_custom_emoji_html: "%{name} updated emoji %{target}" update_domain_block_html: "%{name} updated domain block for %{target}" + update_ip_block_html: "%{name} changed rule for IP %{target}" update_status_html: "%{name} updated post by %{target}" update_user_role_html: "%{name} changed %{target} role" empty: No logs found. diff --git a/config/routes.rb b/config/routes.rb index 1168c9aee..8694a6436 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -602,6 +602,12 @@ Rails.application.routes.draw do post :measures, to: 'measures#create' post :dimensions, to: 'dimensions#create' post :retention, to: 'retention#create' + + resources :canonical_email_blocks, only: [:index, :create, :show, :destroy] do + collection do + post :test + end + end end end diff --git a/db/migrate/20220827195229_change_canonical_email_blocks_nullable.rb b/db/migrate/20220827195229_change_canonical_email_blocks_nullable.rb new file mode 100644 index 000000000..5b3ec4727 --- /dev/null +++ b/db/migrate/20220827195229_change_canonical_email_blocks_nullable.rb @@ -0,0 +1,5 @@ +class ChangeCanonicalEmailBlocksNullable < ActiveRecord::Migration[6.1] + def change + safety_assured { change_column :canonical_email_blocks, :reference_account_id, :bigint, null: true, default: nil } + end +end diff --git a/db/schema.rb b/db/schema.rb index 83fd9549c..db22f538a 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.define(version: 2022_08_24_164532) do +ActiveRecord::Schema.define(version: 2022_08_27_195229) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -296,7 +296,7 @@ ActiveRecord::Schema.define(version: 2022_08_24_164532) do create_table "canonical_email_blocks", force: :cascade do |t| t.string "canonical_email_hash", default: "", null: false - t.bigint "reference_account_id", null: false + t.bigint "reference_account_id" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.index ["canonical_email_hash"], name: "index_canonical_email_blocks_on_canonical_email_hash", unique: true diff --git a/lib/mastodon/canonical_email_blocks_cli.rb b/lib/mastodon/canonical_email_blocks_cli.rb index 64b72e603..ec228d466 100644 --- a/lib/mastodon/canonical_email_blocks_cli.rb +++ b/lib/mastodon/canonical_email_blocks_cli.rb @@ -18,17 +18,15 @@ module Mastodon When suspending a local user, a hash of a "canonical" version of their e-mail address is stored to prevent them from signing up again. - This command can be used to find whether a known email address is blocked, - and if so, which account it was attached to. + This command can be used to find whether a known email address is blocked. LONG_DESC def find(email) - accts = CanonicalEmailBlock.find_blocks(email).map(&:reference_account).map(&:acct).to_a + accts = CanonicalEmailBlock.matching_email(email) + if accts.empty? - say("#{email} is not blocked", :yellow) + say("#{email} is not blocked", :green) else - accts.each do |acct| - say(acct, :white) - end + say("#{email} is blocked", :red) end end @@ -40,24 +38,13 @@ module Mastodon This command allows removing a canonical email block. LONG_DESC def remove(email) - blocks = CanonicalEmailBlock.find_blocks(email) + blocks = CanonicalEmailBlock.matching_email(email) + if blocks.empty? - say("#{email} is not blocked", :yellow) + say("#{email} is not blocked", :green) else blocks.destroy_all - say("Removed canonical email block for #{email}", :green) - end - end - - private - - def color(processed, failed) - if !processed.zero? && failed.zero? - :green - elsif failed.zero? - :yellow - else - :red + say("Unblocked #{email}", :green) end end end -- cgit From 2a7766dcc958ad18df761de50f9da5164f1a2e8f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 28 Aug 2022 03:37:55 +0200 Subject: Add admin API for managing e-mail domain blocks (#19066) --- .../api/v1/admin/email_domain_blocks_controller.rb | 90 ++++++++++++++++++++++ app/models/email_domain_block.rb | 1 + .../rest/admin/email_domain_block_serializer.rb | 9 +++ config/routes.rb | 1 + 4 files changed, 101 insertions(+) create mode 100644 app/controllers/api/v1/admin/email_domain_blocks_controller.rb create mode 100644 app/serializers/rest/admin/email_domain_block_serializer.rb (limited to 'app/controllers') diff --git a/app/controllers/api/v1/admin/email_domain_blocks_controller.rb b/app/controllers/api/v1/admin/email_domain_blocks_controller.rb new file mode 100644 index 000000000..ac16f70b0 --- /dev/null +++ b/app/controllers/api/v1/admin/email_domain_blocks_controller.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +class Api::V1::Admin::EmailDomainBlocksController < Api::BaseController + include Authorization + include AccountableConcern + + LIMIT = 100 + + before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:email_domain_blocks' }, only: [:index, :show] + before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:email_domain_blocks' }, except: [:index, :show] + before_action :set_email_domain_blocks, only: :index + before_action :set_email_domain_block, only: [:show, :destroy] + + after_action :verify_authorized + after_action :insert_pagination_headers, only: :index + + PAGINATION_PARAMS = %i( + limit + ).freeze + + def create + authorize :email_domain_block, :create? + + @email_domain_block = EmailDomainBlock.create!(resource_params) + log_action :create, @email_domain_block + + render json: @email_domain_block, serializer: REST::Admin::EmailDomainBlockSerializer + end + + def index + authorize :email_domain_block, :index? + render json: @email_domain_blocks, each_serializer: REST::Admin::EmailDomainBlockSerializer + end + + def show + authorize @email_domain_block, :show? + render json: @email_domain_block, serializer: REST::Admin::EmailDomainBlockSerializer + end + + def destroy + authorize @email_domain_block, :destroy? + + @email_domain_block.destroy! + log_action :destroy, @email_domain_block + + render json: @email_domain_block, serializer: REST::Admin::EmailDomainBlockSerializer + end + + private + + def set_email_domain_blocks + @email_domain_blocks = EmailDomainBlock.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + end + + def set_email_domain_block + @email_domain_block = EmailDomainBlock.find(params[:id]) + end + + def resource_params + params.permit(:domain) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_admin_email_domain_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue? + end + + def prev_path + api_v1_admin_email_domain_blocks_url(pagination_params(min_id: pagination_since_id)) unless @email_domain_blocks.empty? + end + + def pagination_max_id + @email_domain_blocks.last.id + end + + def pagination_since_id + @email_domain_blocks.first.id + end + + def records_continue? + @email_domain_blocks.size == limit_param(LIMIT) + end + + def pagination_params(core_params) + params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params) + end +end diff --git a/app/models/email_domain_block.rb b/app/models/email_domain_block.rb index 661f6727d..10a0e5102 100644 --- a/app/models/email_domain_block.rb +++ b/app/models/email_domain_block.rb @@ -17,6 +17,7 @@ class EmailDomainBlock < ApplicationRecord ) include DomainNormalizable + include Paginable belongs_to :parent, class_name: 'EmailDomainBlock', optional: true has_many :children, class_name: 'EmailDomainBlock', foreign_key: :parent_id, inverse_of: :parent, dependent: :destroy diff --git a/app/serializers/rest/admin/email_domain_block_serializer.rb b/app/serializers/rest/admin/email_domain_block_serializer.rb new file mode 100644 index 000000000..a026ff680 --- /dev/null +++ b/app/serializers/rest/admin/email_domain_block_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class REST::Admin::EmailDomainBlockSerializer < ActiveModel::Serializer + attributes :id, :domain, :created_at, :history + + def id + object.id.to_s + end +end diff --git a/config/routes.rb b/config/routes.rb index 8694a6436..13a4a1618 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -591,6 +591,7 @@ Rails.application.routes.draw do resources :domain_allows, only: [:index, :show, :create, :destroy] resources :domain_blocks, only: [:index, :show, :update, :create, :destroy] + resources :email_domain_blocks, only: [:index, :show, :create, :destroy] resources :ip_blocks, only: [:index, :show, :update, :create, :destroy] namespace :trends do -- cgit From c99c106ef08d44591d6b7802ea6f9914ea2842bd Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 28 Aug 2022 03:45:07 +0200 Subject: Change following and followers API to be accessible without being logged in (#18964) --- app/controllers/api/v1/accounts/follower_accounts_controller.rb | 2 +- app/controllers/api/v1/accounts/following_accounts_controller.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/api/v1/accounts/follower_accounts_controller.rb b/app/controllers/api/v1/accounts/follower_accounts_controller.rb index a665863eb..b61de13b9 100644 --- a/app/controllers/api/v1/accounts/follower_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::V1::Accounts::FollowerAccountsController < Api::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:accounts' } + before_action -> { authorize_if_got_token! :read, :'read:accounts' } before_action :set_account after_action :insert_pagination_headers diff --git a/app/controllers/api/v1/accounts/following_accounts_controller.rb b/app/controllers/api/v1/accounts/following_accounts_controller.rb index 7d885a212..37d3c2d78 100644 --- a/app/controllers/api/v1/accounts/following_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::V1::Accounts::FollowingAccountsController < Api::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:accounts' } + before_action -> { authorize_if_got_token! :read, :'read:accounts' } before_action :set_account after_action :insert_pagination_headers -- cgit From c57907737a35d05d4bb936acd662df6ce725456f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 28 Aug 2022 03:45:19 +0200 Subject: Change search API to be accessible without being logged in (#18963) But with the resolve option turned off --- app/controllers/api/v2/search_controller.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb index a30560133..e384ecbaf 100644 --- a/app/controllers/api/v2/search_controller.rb +++ b/app/controllers/api/v2/search_controller.rb @@ -5,8 +5,7 @@ class Api::V2::SearchController < Api::BaseController RESULTS_LIMIT = 20 - before_action -> { doorkeeper_authorize! :read, :'read:search' } - before_action :require_user! + before_action -> { authorize_if_got_token! :read, :'read:search' } def index @search = Search.new(search_results) @@ -24,7 +23,7 @@ class Api::V2::SearchController < Api::BaseController params[:q], current_account, limit_param(RESULTS_LIMIT), - search_params.merge(resolve: truthy_param?(:resolve), exclude_unreviewed: truthy_param?(:exclude_unreviewed)) + search_params.merge(resolve: user_signed_in? ? truthy_param?(:resolve) : false, exclude_unreviewed: truthy_param?(:exclude_unreviewed)) ) end -- cgit From 0fece174f6fd4dcdf53974889a1fe5618fb34bf3 Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 31 Aug 2022 11:55:27 +0200 Subject: Fix error when accessing /filters/:id/statuses on glitch-soc (#1837) I failed to account for glitch-soc's theming system when merging from upstream. --- app/controllers/filters/statuses_controller.rb | 5 +++++ app/views/filters/statuses/index.html.haml | 3 --- 2 files changed, 5 insertions(+), 3 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/filters/statuses_controller.rb b/app/controllers/filters/statuses_controller.rb index cc493c22c..4f63de7b6 100644 --- a/app/controllers/filters/statuses_controller.rb +++ b/app/controllers/filters/statuses_controller.rb @@ -6,6 +6,7 @@ class Filters::StatusesController < ApplicationController before_action :authenticate_user! before_action :set_filter before_action :set_status_filters + before_action :set_pack before_action :set_body_classes PER_PAGE = 20 @@ -25,6 +26,10 @@ class Filters::StatusesController < ApplicationController private + def set_pack + use_pack 'admin' + end + def set_filter @filter = current_account.custom_filters.find(params[:filter_id]) end diff --git a/app/views/filters/statuses/index.html.haml b/app/views/filters/statuses/index.html.haml index 886de58fa..eaa39e170 100644 --- a/app/views/filters/statuses/index.html.haml +++ b/app/views/filters/statuses/index.html.haml @@ -1,6 +1,3 @@ -- content_for :header_tags do - = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' - - content_for :page_title do = t('filters.statuses.index.title') \- -- cgit From 2750a7a0e6baac8753f7fceb1c747f56718cd93f Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 8 Sep 2022 09:44:36 +0200 Subject: Fix REST API sometimes returning HTML on error (#19135) Fixes #19115 --- app/controllers/api/base_controller.rb | 6 ++++++ 1 file changed, 6 insertions(+) (limited to 'app/controllers') diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 2e393fbb6..7ce6599c5 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -131,4 +131,10 @@ class Api::BaseController < ApplicationController def disallow_unauthenticated_api_access? authorized_fetch_mode? end + + private + + def respond_with_error(code) + render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code + end end -- cgit From 1145dbd327ae9b56357cc488801d723051f58e0b Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 20 Sep 2022 23:30:26 +0200 Subject: Improve error reporting and logging when processing remote accounts (#15605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a more descriptive PrivateNetworkAddressError exception class * Remove unnecessary exception class to rescue clause * Remove unnecessary include to JsonLdHelper * Give more neutral error message when too many webfinger redirects * Remove unnecessary guard condition * Rework how “ActivityPub::FetchRemoteAccountService” handles errors Add “suppress_errors” keyword argument to avoid raising errors in ActivityPub::FetchRemoteAccountService#call (default/previous behavior). * Rework how “ActivityPub::FetchRemoteKeyService” handles errors Add “suppress_errors” keyword argument to avoid raising errors in ActivityPub::FetchRemoteKeyService#call (default/previous behavior). * Fix Webfinger::RedirectError not being a subclass of Webfinger::Error * Add suppress_errors option to ResolveAccountService Defaults to true (to preserve previous behavior). If set to false, errors will be raised instead of caught, allowing the caller to be informed of what went wrong. * Return more precise error when failing to fetch account signing AP payloads * Add tests * Fixes * Refactor error handling a bit * Fix various issues * Add specific error when provided Digest is not 256 bits of base64-encoded data * Please CodeClimate * Improve webfinger error reporting --- app/controllers/concerns/signature_verification.rb | 46 ++++++++++++++----- app/lib/request.rb | 6 +-- app/lib/webfinger.rb | 2 +- .../activitypub/fetch_remote_account_service.rb | 38 +++++++++++----- .../activitypub/fetch_remote_key_service.rb | 34 +++++++++----- .../activitypub/process_account_service.rb | 2 - app/services/resolve_account_service.rb | 12 ++--- lib/exceptions.rb | 9 ++++ .../fetch_remote_account_service_spec.rb | 52 ++++++++++++++++++++++ spec/services/resolve_account_service_spec.rb | 4 +- 10 files changed, 158 insertions(+), 47 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 4dd0cac55..89dc828f4 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -93,11 +93,15 @@ module SignatureVerification return account unless verify_signature(account, signature, compare_signed_string).nil? - @signature_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)" - @signed_request_account = nil + fail_with! "Verification failed for #{account.username}@#{account.domain} #{account.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)" rescue SignatureVerificationError => e - @signature_verification_failure_reason = e.message - @signed_request_account = nil + fail_with! e.message + rescue HTTP::Error, OpenSSL::SSL::SSLError => e + fail_with! "Failed to fetch remote data: #{e.message}" + rescue Mastodon::UnexptectedResponseError + fail_with! 'Failed to fetch remote data (got unexpected reply from server)' + rescue Stoplight::Error::RedLight + fail_with! 'Fetching attempt skipped because of recent connection failure' end def request_body @@ -106,6 +110,11 @@ module SignatureVerification private + def fail_with!(message) + @signature_verification_failure_reason = message + @signed_request_account = nil + end + def signature_params @signature_params ||= begin raw_signature = request.headers['Signature'] @@ -138,7 +147,17 @@ module SignatureVerification digests = request.headers['Digest'].split(',').map { |digest| digest.split('=', 2) }.map { |key, value| [key.downcase, value] } sha256 = digests.assoc('sha-256') raise SignatureVerificationError, "Mastodon only supports SHA-256 in Digest header. Offered algorithms: #{digests.map(&:first).join(', ')}" if sha256.nil? - raise SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}" if body_digest != sha256[1] + + return if body_digest == sha256[1] + + digest_size = begin + Base64.strict_decode64(sha256[1].strip).length + rescue ArgumentError + raise SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a valid base64 string. Given digest: #{sha256[1]}" + end + + raise SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a SHA-256 digest. Given digest: #{sha256[1]}" if digest_size != 32 + raise SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}" end def verify_signature(account, signature, compare_signed_string) @@ -216,19 +235,20 @@ module SignatureVerification end if key_id.start_with?('acct:') - stoplight_wrap_request { ResolveAccountService.new.call(key_id.gsub(/\Aacct:/, '')) } + stoplight_wrap_request { ResolveAccountService.new.call(key_id.gsub(/\Aacct:/, ''), suppress_errors: false) } elsif !ActivityPub::TagManager.instance.local_uri?(key_id) account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account) - account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false) } + account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false, suppress_errors: false) } account end - rescue Mastodon::HostValidationError - nil + rescue Mastodon::PrivateNetworkAddressError => e + raise SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})" + rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteAccountService::Error, ActivityPub::FetchRemoteKeyService::Error, Webfinger::Error => e + raise SignatureVerificationError, e.message end def stoplight_wrap_request(&block) Stoplight("source:#{request.remote_ip}", &block) - .with_fallback { nil } .with_threshold(1) .with_cool_off_time(5.minutes.seconds) .with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) } @@ -237,6 +257,10 @@ module SignatureVerification def account_refresh_key(account) return if account.local? || !account.activitypub? - ActivityPub::FetchRemoteAccountService.new.call(account.uri, only_key: true) + ActivityPub::FetchRemoteAccountService.new.call(account.uri, only_key: true, suppress_errors: false) + rescue Mastodon::PrivateNetworkAddressError => e + raise SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})" + rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteAccountService::Error, Webfinger::Error => e + raise SignatureVerificationError, e.message end end diff --git a/app/lib/request.rb b/app/lib/request.rb index f5123d776..eac04c798 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -208,7 +208,7 @@ class Request addresses.each do |address| begin - check_private_address(address) + check_private_address(address, host) sock = ::Socket.new(address.is_a?(Resolv::IPv6) ? ::Socket::AF_INET6 : ::Socket::AF_INET, ::Socket::SOCK_STREAM, 0) sockaddr = ::Socket.pack_sockaddr_in(port, address.to_s) @@ -264,10 +264,10 @@ class Request alias new open - def check_private_address(address) + def check_private_address(address, host) addr = IPAddr.new(address.to_s) return if private_address_exceptions.any? { |range| range.include?(addr) } - raise Mastodon::HostValidationError if PrivateAddressCheck.private_address?(addr) + raise Mastodon::PrivateNetworkAddressError, host if PrivateAddressCheck.private_address?(addr) end def private_address_exceptions diff --git a/app/lib/webfinger.rb b/app/lib/webfinger.rb index a681e0815..7c0c10c33 100644 --- a/app/lib/webfinger.rb +++ b/app/lib/webfinger.rb @@ -3,7 +3,7 @@ class Webfinger class Error < StandardError; end class GoneError < Error; end - class RedirectError < StandardError; end + class RedirectError < Error; end class Response attr_reader :uri diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb index 9d01f5386..d7d739c59 100644 --- a/app/services/activitypub/fetch_remote_account_service.rb +++ b/app/services/activitypub/fetch_remote_account_service.rb @@ -5,10 +5,12 @@ class ActivityPub::FetchRemoteAccountService < BaseService include DomainControlHelper include WebfingerHelper + class Error < StandardError; end + SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze # Does a WebFinger roundtrip on each call, unless `only_key` is true - def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false) + def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true) return if domain_not_allowed?(uri) return ActivityPub::TagManager.instance.uri_to_resource(uri, Account) if ActivityPub::TagManager.instance.local_uri?(uri) @@ -18,38 +20,50 @@ class ActivityPub::FetchRemoteAccountService < BaseService else body_to_json(prefetched_body, compare_id: id ? uri : nil) end + rescue Oj::ParseError + raise Error, "Error parsing JSON-LD document #{uri}" end - return if !supported_context? || !expected_type? || (break_on_redirect && @json['movedTo'].present?) + raise Error, "Error fetching actor JSON at #{uri}" if @json.nil? + raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context? + raise Error, "Unexpected object type for actor #{uri} (expected any of: #{SUPPORTED_TYPES})" unless expected_type? + raise Error, "Actor #{uri} has moved to #{@json['movedTo']}" if break_on_redirect && @json['movedTo'].present? @uri = @json['id'] @username = @json['preferredUsername'] @domain = Addressable::URI.parse(@uri).normalized_host - return unless only_key || verified_webfinger? + check_webfinger! unless only_key ActivityPub::ProcessAccountService.new.call(@username, @domain, @json, only_key: only_key, verified_webfinger: !only_key) - rescue Oj::ParseError - nil + rescue Error => e + Rails.logger.debug "Fetching account #{uri} failed: #{e.message}" + raise unless suppress_errors end private - def verified_webfinger? + def check_webfinger! webfinger = webfinger!("acct:#{@username}@#{@domain}") confirmed_username, confirmed_domain = split_acct(webfinger.subject) - return webfinger.link('self', 'href') == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? + if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? + raise Error, "Webfinger response for #{@username}@#{@domain} does not loop back to #{@uri}" if webfinger.link('self', 'href') != @uri + return + end webfinger = webfinger!("acct:#{confirmed_username}@#{confirmed_domain}") @username, @domain = split_acct(webfinger.subject) - return false unless @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? - return false if webfinger.link('self', 'href') != @uri + unless confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero? + raise Webfinger::RedirectError, "Too many webfinger redirects for URI #{uri} (stopped at #{@username}@#{@domain})" + end - true - rescue Webfinger::Error - false + raise Error, "Webfinger response for #{@username}@#{@domain} does not loop back to #{@uri}" if webfinger.link('self', 'href') != @uri + rescue Webfinger::RedirectError => e + raise Error, e.message + rescue Webfinger::Error => e + raise Error, "Webfinger error when resolving #{@username}@#{@domain}: #{e.message}" end def split_acct(acct) diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb index c48288b3b..01008d883 100644 --- a/app/services/activitypub/fetch_remote_key_service.rb +++ b/app/services/activitypub/fetch_remote_key_service.rb @@ -3,9 +3,11 @@ class ActivityPub::FetchRemoteKeyService < BaseService include JsonLdHelper + class Error < StandardError; end + # Returns account that owns the key - def call(uri, id: true, prefetched_body: nil) - return if uri.blank? + def call(uri, id: true, prefetched_body: nil, suppress_errors: true) + raise Error, 'No key URI given' if uri.blank? if prefetched_body.nil? if id @@ -13,7 +15,7 @@ class ActivityPub::FetchRemoteKeyService < BaseService if person? @json = fetch_resource(@json['id'], true) elsif uri != @json['id'] - return + raise Error, "Fetched URI #{uri} has wrong id #{@json['id']}" end else @json = fetch_resource(uri, id) @@ -22,21 +24,29 @@ class ActivityPub::FetchRemoteKeyService < BaseService @json = body_to_json(prefetched_body, compare_id: id ? uri : nil) end - return unless supported_context?(@json) && expected_type? - return find_account(@json['id'], @json) if person? + raise Error, "Unable to fetch key JSON at #{uri}" if @json.nil? + raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?(@json) + raise Error, "Unexpected object type for key #{uri}" unless expected_type? + return find_account(@json['id'], @json, suppress_errors) if person? @owner = fetch_resource(owner_uri, true) - return unless supported_context?(@owner) && confirmed_owner? + raise Error, "Unable to fetch actor JSON #{owner_uri}" if @owner.nil? + raise Error, "Unsupported JSON-LD context for document #{owner_uri}" unless supported_context?(@owner) + raise Error, "Unexpected object type for actor #{owner_uri} (expected any of: #{SUPPORTED_TYPES})" unless expected_owner_type? + raise Error, "publicKey id for #{owner_uri} does not correspond to #{@json['id']}" unless confirmed_owner? - find_account(owner_uri, @owner) + find_account(owner_uri, @owner, suppress_errors) + rescue Error => e + Rails.logger.debug "Fetching key #{uri} failed: #{e.message}" + raise unless suppress_errors end private - def find_account(uri, prefetched_body) + def find_account(uri, prefetched_body, suppress_errors) account = ActivityPub::TagManager.instance.uri_to_resource(uri, Account) - account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_body: prefetched_body) + account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_body: prefetched_body, suppress_errors: suppress_errors) account end @@ -56,7 +66,11 @@ class ActivityPub::FetchRemoteKeyService < BaseService @owner_uri ||= value_or_id(@json['owner']) end + def expected_owner_type? + equals_or_includes_any?(@owner['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) + end + def confirmed_owner? - equals_or_includes_any?(@owner['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) && value_or_id(@owner['publicKey']) == @json['id'] + value_or_id(@owner['publicKey']) == @json['id'] end end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 34750dba6..456b3524b 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -32,8 +32,6 @@ class ActivityPub::ProcessAccountService < BaseService process_duplicate_accounts! if @options[:verified_webfinger] end - return if @account.nil? - after_protocol_change! if protocol_changed? after_key_change! if key_changed? && !@options[:signed_with_known_key] clear_tombstones! if key_changed? diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb index b55e45409..e3b370968 100644 --- a/app/services/resolve_account_service.rb +++ b/app/services/resolve_account_service.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class ResolveAccountService < BaseService - include JsonLdHelper include DomainControlHelper include WebfingerHelper include Redisable @@ -13,6 +12,7 @@ class ResolveAccountService < BaseService # @param [Hash] options # @option options [Boolean] :redirected Do not follow further Webfinger redirects # @option options [Boolean] :skip_webfinger Do not attempt any webfinger query or refreshing account data + # @option options [Boolean] :suppress_errors When failing, return nil instead of raising an error # @return [Account] def call(uri, options = {}) return if uri.blank? @@ -52,15 +52,15 @@ class ResolveAccountService < BaseService # either needs to be created, or updated from fresh data fetch_account! - rescue Webfinger::Error, Oj::ParseError => e + rescue Webfinger::Error => e Rails.logger.debug "Webfinger query for #{@uri} failed: #{e}" - nil + raise unless @options[:suppress_errors] end private def process_options!(uri, options) - @options = options + @options = { suppress_errors: true }.merge(options) if uri.is_a?(Account) @account = uri @@ -96,7 +96,7 @@ class ResolveAccountService < BaseService @username, @domain = split_acct(@webfinger.subject) unless confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero? - raise Webfinger::RedirectError, "The URI #{uri} tries to hijack #{@username}@#{@domain}" + raise Webfinger::RedirectError, "Too many webfinger redirects for URI #{uri} (stopped at #{@username}@#{@domain})" end rescue Webfinger::GoneError @gone = true @@ -110,7 +110,7 @@ class ResolveAccountService < BaseService return unless activitypub_ready? with_lock("resolve:#{@username}@#{@domain}") do - @account = ActivityPub::FetchRemoteAccountService.new.call(actor_url) + @account = ActivityPub::FetchRemoteAccountService.new.call(actor_url, suppress_errors: @options[:suppress_errors]) end @account diff --git a/lib/exceptions.rb b/lib/exceptions.rb index 0c677b660..3c5ba226b 100644 --- a/lib/exceptions.rb +++ b/lib/exceptions.rb @@ -25,4 +25,13 @@ module Mastodon end end end + + class PrivateNetworkAddressError < HostValidationError + attr_reader :host + + def initialize(host) + @host = host + super() + end + end end diff --git a/spec/services/activitypub/fetch_remote_account_service_spec.rb b/spec/services/activitypub/fetch_remote_account_service_spec.rb index aa13f0a9b..ec6f1f41d 100644 --- a/spec/services/activitypub/fetch_remote_account_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_account_service_spec.rb @@ -119,6 +119,58 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do include_examples 'sets profile data' end + context 'when WebFinger returns a different URI' do + let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/bob' }] } } + + before do + stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) + stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) + end + + it 'fetches resource' do + account + expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once + end + + it 'looks up webfinger' do + account + expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once + end + + it 'does not create account' do + expect(account).to be_nil + end + end + + context 'when WebFinger returns a different URI after a redirection' do + let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/bob' }] } } + + before do + stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) + stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) + stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) + end + + it 'fetches resource' do + account + expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once + end + + it 'looks up webfinger' do + account + expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once + end + + it 'looks up "redirected" webfinger' do + account + expect(a_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af')).to have_been_made.once + end + + it 'does not create account' do + expect(account).to be_nil + end + end + context 'with wrong id' do it 'does not create account' do expect(subject.call('https://fake.address/@foo', prefetched_body: Oj.dump(actor))).to be_nil diff --git a/spec/services/resolve_account_service_spec.rb b/spec/services/resolve_account_service_spec.rb index 8c302e1d8..654606bea 100644 --- a/spec/services/resolve_account_service_spec.rb +++ b/spec/services/resolve_account_service_spec.rb @@ -137,8 +137,8 @@ RSpec.describe ResolveAccountService, type: :service do stub_request(:get, 'https://evil.example.com/.well-known/webfinger?resource=acct:foo@evil.example.com').to_return(body: Oj.dump(webfinger2), headers: { 'Content-Type': 'application/jrd+json' }) end - it 'returns new remote account' do - expect { subject.call('Foo@redirected.example.com') }.to raise_error Webfinger::RedirectError + it 'does not return a new remote account' do + expect(subject.call('Foo@redirected.example.com')).to be_nil end end -- cgit From 50948b46aabc0756d85bc6641f0bd3bcc09bf7d4 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 20 Sep 2022 23:51:21 +0200 Subject: Add ability to filter followed accounts' posts by language (#19095) --- app/controllers/api/v1/accounts_controller.rb | 6 +- .../mastodon/features/account/components/header.js | 5 + .../features/account_timeline/components/header.js | 6 + .../containers/header_container.js | 8 +- .../features/subscribed_languages_modal/index.js | 121 +++++++++++++++++++++ .../mastodon/features/ui/components/modal_root.js | 2 + .../mastodon/locales/defaultMessages.json | 25 +++++ app/javascript/mastodon/locales/en.json | 4 + app/lib/feed_manager.rb | 2 + app/models/concerns/account_interactions.rb | 23 ++-- app/models/export.rb | 4 +- app/models/follow.rb | 4 +- app/models/follow_request.rb | 4 +- app/serializers/rest/relationship_serializer.rb | 7 +- app/services/follow_service.rb | 13 ++- app/services/import_service.rb | 2 +- app/validators/language_validator.rb | 21 ++++ app/workers/refollow_worker.rb | 7 +- app/workers/unfollow_follow_worker.rb | 9 +- .../20220829192633_add_languages_to_follows.rb | 5 + ...20829192658_add_languages_to_follow_requests.rb | 5 + db/schema.rb | 4 +- .../controllers/api/v1/accounts_controller_spec.rb | 11 ++ .../exports/following_accounts_controller_spec.rb | 2 +- spec/lib/feed_manager_spec.rb | 12 ++ spec/models/concerns/account_interactions_spec.rb | 2 +- spec/models/export_spec.rb | 4 +- spec/models/follow_request_spec.rb | 2 +- spec/services/follow_service_spec.rb | 13 +++ spec/workers/refollow_worker_spec.rb | 4 +- 30 files changed, 298 insertions(+), 39 deletions(-) create mode 100644 app/javascript/mastodon/features/subscribed_languages_modal/index.js create mode 100644 app/validators/language_validator.rb create mode 100644 db/migrate/20220829192633_add_languages_to_follows.rb create mode 100644 db/migrate/20220829192658_add_languages_to_follow_requests.rb (limited to 'app/controllers') diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 5537cc9b0..be84720aa 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -30,12 +30,12 @@ class Api::V1::AccountsController < Api::BaseController self.response_body = Oj.dump(response.body) self.status = response.status rescue ActiveRecord::RecordInvalid => e - render json: ValidationErrorFormatter.new(e, :'account.username' => :username, :'invite_request.text' => :reason).as_json, status: :unprocessable_entity + render json: ValidationErrorFormatter.new(e, 'account.username': :username, 'invite_request.text': :reason).as_json, status: :unprocessable_entity end def follow - follow = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, with_rate_limit: true) - options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify? } }, requested_map: { @account.id => false } } + follow = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, languages: params.key?(:languages) ? params[:languages] : nil, with_rate_limit: true) + options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify?, languages: follow.languages } }, requested_map: { @account.id => false } } render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(**options) end diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 1ad9341c7..8f2753c35 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -51,6 +51,7 @@ const messages = defineMessages({ unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' }, add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' }, admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, + languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' }, }); const dateFormatOptions = { @@ -85,6 +86,7 @@ class Header extends ImmutablePureComponent { onEndorseToggle: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired, onEditAccountNote: PropTypes.func.isRequired, + onChangeLanguages: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, domain: PropTypes.string.isRequired, hidden: PropTypes.bool, @@ -212,6 +214,9 @@ class Header extends ImmutablePureComponent { } else { menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle }); } + + menu.push({ text: intl.formatMessage(messages.languages), action: this.props.onChangeLanguages }); + menu.push(null); } menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle }); diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js index fab0bc597..f9838442f 100644 --- a/app/javascript/mastodon/features/account_timeline/components/header.js +++ b/app/javascript/mastodon/features/account_timeline/components/header.js @@ -22,6 +22,7 @@ export default class Header extends ImmutablePureComponent { onUnblockDomain: PropTypes.func.isRequired, onEndorseToggle: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired, + onChangeLanguages: PropTypes.func.isRequired, hideTabs: PropTypes.bool, domain: PropTypes.string.isRequired, hidden: PropTypes.bool, @@ -91,6 +92,10 @@ export default class Header extends ImmutablePureComponent { this.props.onEditAccountNote(this.props.account); } + handleChangeLanguages = () => { + this.props.onChangeLanguages(this.props.account); + } + render () { const { account, hidden, hideTabs } = this.props; @@ -117,6 +122,7 @@ export default class Header extends ImmutablePureComponent { onEndorseToggle={this.handleEndorseToggle} onAddToList={this.handleAddToList} onEditAccountNote={this.handleEditAccountNote} + onChangeLanguages={this.handleChangeLanguages} domain={this.props.domain} hidden={hidden} /> diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js index 371794dd7..3d6eb487d 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -121,12 +121,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(unblockDomain(domain)); }, - onAddToList(account){ + onAddToList (account) { dispatch(openModal('LIST_ADDER', { accountId: account.get('id'), })); }, + onChangeLanguages (account) { + dispatch(openModal('SUBSCRIBED_LANGUAGES', { + accountId: account.get('id'), + })); + }, + }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/javascript/mastodon/features/subscribed_languages_modal/index.js b/app/javascript/mastodon/features/subscribed_languages_modal/index.js new file mode 100644 index 000000000..6a1bb2c47 --- /dev/null +++ b/app/javascript/mastodon/features/subscribed_languages_modal/index.js @@ -0,0 +1,121 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { is, List as ImmutableList, Set as ImmutableSet } from 'immutable'; +import { languages as preloadedLanguages } from 'mastodon/initial_state'; +import Option from 'mastodon/features/report/components/option'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import IconButton from 'mastodon/components/icon_button'; +import Button from 'mastodon/components/button'; +import { followAccount } from 'mastodon/actions/accounts'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +const getAccountLanguages = createSelector([ + (state, accountId) => state.getIn(['timelines', `account:${accountId}`, 'items'], ImmutableList()), + state => state.get('statuses'), +], (statusIds, statuses) => + new ImmutableSet(statusIds.map(statusId => statuses.get(statusId)).filter(status => !status.get('reblog')).map(status => status.get('language')))); + +const mapStateToProps = (state, { accountId }) => ({ + acct: state.getIn(['accounts', accountId, 'acct']), + availableLanguages: getAccountLanguages(state, accountId), + selectedLanguages: ImmutableSet(state.getIn(['relationships', accountId, 'languages']) || ImmutableList()), +}); + +const mapDispatchToProps = (dispatch, { accountId }) => ({ + + onSubmit (languages) { + dispatch(followAccount(accountId, { languages })); + }, + +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class SubscribedLanguagesModal extends ImmutablePureComponent { + + static propTypes = { + accountId: PropTypes.string.isRequired, + acct: PropTypes.string.isRequired, + availableLanguages: ImmutablePropTypes.setOf(PropTypes.string), + selectedLanguages: ImmutablePropTypes.setOf(PropTypes.string), + onClose: PropTypes.func.isRequired, + languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), + intl: PropTypes.object.isRequired, + submit: PropTypes.func.isRequired, + }; + + static defaultProps = { + languages: preloadedLanguages, + }; + + state = { + selectedLanguages: this.props.selectedLanguages, + }; + + handleLanguageToggle = (value, checked) => { + const { selectedLanguages } = this.state; + + if (checked) { + this.setState({ selectedLanguages: selectedLanguages.add(value) }); + } else { + this.setState({ selectedLanguages: selectedLanguages.delete(value) }); + } + }; + + handleSubmit = () => { + this.props.onSubmit(this.state.selectedLanguages.toArray()); + this.props.onClose(); + } + + renderItem (value) { + const language = this.props.languages.find(language => language[0] === value); + const checked = this.state.selectedLanguages.includes(value); + + return ( +